files.rs
⎇
Raw
1//! File API: listing, download/preview/content, editor save, mutations,
2//! upload, access control and path-safety.
3
4use crate::common::*;
5use axum::http::StatusCode;
6use serde_json::json;
7
8/// Root id for the whole-root (".") user root is 1 (first row inserted).
9const ROOT: i64 = 1;
10
11fn root_path(rel: &str) -> String {
12 // No trailing slash for the bare root: axum's routes are
13 // `/api/files/{root_id}` and `/api/files/{root_id}/{*path}`.
14 if rel.is_empty() {
15 format!("/api/files/{ROOT}")
16 } else {
17 format!("/api/files/{ROOT}/{rel}")
18 }
19}
20
21#[tokio::test]
22async fn list_root_sorted_folders_first() {
23 let env = Env::new().await;
24 let admin = env.admin().await;
25 let r = admin.get(&root_path("")).await;
26 assert_eq!(r.status, StatusCode::OK);
27 let j = r.json();
28 let entries = j["entries"].as_array().unwrap();
29 let names: Vec<&str> = entries
30 .iter()
31 .map(|e| e["name"].as_str().unwrap())
32 .collect();
33 assert_eq!(
34 names,
35 vec![
36 "docs",
37 "src",
38 "blob.bin",
39 "config.json",
40 "editme.txt",
41 "notes.md"
42 ]
43 );
44 // Entry fields.
45 let docs = &entries[0];
46 assert_eq!(docs["is_dir"], true);
47 let editme = entries.iter().find(|e| e["name"] == "editme.txt").unwrap();
48 assert_eq!(editme["is_dir"], false);
49 assert_eq!(editme["size"], 2);
50 assert!(editme["mtime"].as_str().unwrap().ends_with('Z'));
51}
52
53#[tokio::test]
54async fn list_subdir_and_errors() {
55 let env = Env::new().await;
56 let admin = env.admin().await;
57
58 let r = admin.get(&root_path("docs")).await;
59 let j = r.json();
60 let names: Vec<&str> = j
61 .get("entries")
62 .unwrap()
63 .as_array()
64 .unwrap()
65 .iter()
66 .map(|e| e["name"].as_str().unwrap())
67 .collect();
68 assert_eq!(names, vec!["inner", "a.txt"]);
69
70 // Missing path → 404.
71 assert_eq!(
72 admin.get(&root_path("nope")).await.status,
73 StatusCode::NOT_FOUND
74 );
75 // Listing a file → 400.
76 assert_eq!(
77 admin.get(&root_path("editme.txt")).await.status,
78 StatusCode::BAD_REQUEST
79 );
80 // Unknown root id → 403.
81 assert_eq!(
82 admin.get("/api/files/999").await.status,
83 StatusCode::FORBIDDEN
84 );
85 // No session → 401.
86 let anon = Client::new(env.app.clone());
87 assert_eq!(
88 anon.get(&root_path("")).await.status,
89 StatusCode::UNAUTHORIZED
90 );
91}
92
93#[tokio::test]
94async fn path_traversal_is_blocked() {
95 let env = Env::new().await;
96 let admin = env.admin().await;
97
98 // Encoded `..` segments reach the handler and are rejected.
99 let r = admin.get("/api/files/1/%2e%2e%2f%2e%2e%2fetc").await;
100 assert!(
101 r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND,
102 "traversal returned {:?}",
103 r.status
104 );
105 // Literal `..` segments: must never succeed.
106 let r = admin.get("/api/files/1/../../etc").await;
107 assert_ne!(
108 r.status,
109 StatusCode::OK,
110 "literal traversal must not be served"
111 );
112 // Traversal inside a deeper path.
113 let r = admin.get("/api/files/1/docs/..%2f..%2fsrc").await;
114 assert!(
115 r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND,
116 "deep traversal returned {:?}",
117 r.status
118 );
119}
120
121#[tokio::test]
122async fn download_single_file() {
123 let env = Env::new().await;
124 let admin = env.admin().await;
125 let r = admin
126 .get(&format!("{}?action=download", root_path("editme.txt")))
127 .await;
128 assert_eq!(r.status, StatusCode::OK);
129 assert_eq!(
130 r.header("content-disposition").as_deref(),
131 Some("attachment; filename=\"editme.txt\"; filename*=UTF-8''editme.txt")
132 );
133 assert_eq!(r.header("content-type").as_deref(), Some("text/plain"));
134 assert_eq!(r.body, b"v1");
135 // Binary content survives.
136 let r = admin
137 .get(&format!("{}?action=download", root_path("blob.bin")))
138 .await;
139 assert_eq!(r.body, (0..64u8).collect::<Vec<_>>());
140}
141
142#[tokio::test]
143async fn download_encodes_non_ascii_and_control_characters_in_filename() {
144 let env = Env::new().await;
145 let admin = env.admin().await;
146 std::fs::write(env.file("Übersicht \"q\"\t.txt"), "x").unwrap();
147 let r = admin
148 .get(&format!(
149 "{}?action=download",
150 root_path("%C3%9Cbersicht%20%22q%22%09.txt")
151 ))
152 .await;
153 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
154 assert_eq!(
155 r.header("content-disposition").as_deref(),
156 Some(
157 "attachment; filename=\"_bersicht _q__.txt\"; \
158 filename*=UTF-8''%C3%9Cbersicht%20%22q%22%09.txt"
159 )
160 );
161}
162
163#[tokio::test]
164async fn download_honours_single_byte_ranges() {
165 let env = Env::new().await;
166 let admin = env.admin().await;
167 let url = format!("{}?action=preview", root_path("blob.bin"));
168 let r = admin.get(&url).await;
169 assert_eq!(r.status, StatusCode::OK);
170 assert_eq!(r.header("accept-ranges").as_deref(), Some("bytes"));
171
172 let get = |range: &'static str| {
173 let admin = &admin;
174 let url = url.clone();
175 async move {
176 admin
177 .raw(
178 axum::http::Method::GET,
179 &url,
180 &[("range", range)],
181 Vec::new(),
182 )
183 .await
184 }
185 };
186 let r = get("bytes=10-19").await;
187 assert_eq!(r.status, StatusCode::PARTIAL_CONTENT);
188 assert_eq!(r.header("content-range").as_deref(), Some("bytes 10-19/64"));
189 assert_eq!(r.header("content-length").as_deref(), Some("10"));
190 assert_eq!(r.body, (10..20u8).collect::<Vec<_>>());
191
192 // A whole-file range is still a 206 with a `Content-Range` (Firefox).
193 let r = get("bytes=0-").await;
194 assert_eq!(r.status, StatusCode::PARTIAL_CONTENT);
195 assert_eq!(r.header("content-range").as_deref(), Some("bytes 0-63/64"));
196 assert_eq!(r.body.len(), 64);
197
198 // Open end and suffix forms; an end past EOF is clamped.
199 let r = get("bytes=60-").await;
200 assert_eq!(r.body, (60..64u8).collect::<Vec<_>>());
201 let r = get("bytes=-4").await;
202 assert_eq!(r.body, (60..64u8).collect::<Vec<_>>());
203 let r = get("bytes=62-999").await;
204 assert_eq!(r.header("content-range").as_deref(), Some("bytes 62-63/64"));
205
206 // Out of range, several ranges, or garbage → 416 with the size.
207 for range in ["bytes=64-70", "bytes=0-1,4-5", "items=1-2"] {
208 let r = get(range).await;
209 assert_eq!(r.status, StatusCode::RANGE_NOT_SATISFIABLE, "{range}");
210 assert_eq!(r.header("content-range").as_deref(), Some("bytes */64"));
211 }
212}
213
214#[tokio::test]
215async fn download_folder_as_all_archive_formats() {
216 let env = Env::new().await;
217 let admin = env.admin().await;
218 let path = format!("{}?action=download", root_path("docs"));
219
220 let r = admin.get(&format!("{path}&format=zip")).await;
221 assert_eq!(r.status, StatusCode::OK);
222 assert_eq!(r.header("content-type").as_deref(), Some("application/zip"));
223 assert_eq!(
224 r.header("content-disposition").as_deref(),
225 Some("attachment; filename=\"docs.zip\"; filename*=UTF-8''docs.zip")
226 );
227 let map = zip_map(&r.body);
228 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
229 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
230
231 let r = admin.get(&format!("{path}&format=tar")).await;
232 assert_eq!(
233 r.header("content-type").as_deref(),
234 Some("application/x-tar")
235 );
236 assert_eq!(
237 r.header("content-disposition").as_deref(),
238 Some("attachment; filename=\"docs.tar\"; filename*=UTF-8''docs.tar")
239 );
240 let map = tar_map(&r.body, Compress::None);
241 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
242 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
243
244 let r = admin.get(&format!("{path}&format=tar.gz")).await;
245 assert_eq!(
246 r.header("content-type").as_deref(),
247 Some("application/gzip")
248 );
249 assert_eq!(
250 r.header("content-disposition").as_deref(),
251 Some("attachment; filename=\"docs.tar.gz\"; filename*=UTF-8''docs.tar.gz")
252 );
253 let map = tar_map(&r.body, Compress::Gz);
254 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
255
256 let r = admin.get(&format!("{path}&format=tar.zst")).await;
257 assert_eq!(
258 r.header("content-type").as_deref(),
259 Some("application/zstd")
260 );
261 assert_eq!(
262 r.header("content-disposition").as_deref(),
263 Some("attachment; filename=\"docs.tar.zst\"; filename*=UTF-8''docs.tar.zst")
264 );
265 let map = tar_map(&r.body, Compress::Zst);
266 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
267}
268
269#[tokio::test]
270async fn download_folder_requires_valid_format() {
271 let env = Env::new().await;
272 let admin = env.admin().await;
273 let path = format!("{}?action=download", root_path("docs"));
274 // No format → 400.
275 assert_eq!(admin.get(&path).await.status, StatusCode::BAD_REQUEST);
276 // Unknown format → 400.
277 assert_eq!(
278 admin.get(&format!("{path}&format=rar")).await.status,
279 StatusCode::BAD_REQUEST
280 );
281 // Downloading a file with a format is fine (format ignored).
282 let r = admin
283 .get(&format!(
284 "{}?action=download&format=zip",
285 root_path("editme.txt")
286 ))
287 .await;
288 assert_eq!(r.status, StatusCode::OK);
289 assert_eq!(r.body, b"v1");
290}
291
292#[tokio::test]
293async fn preview_serves_inline_and_rejects_dirs() {
294 let env = Env::new().await;
295 let admin = env.admin().await;
296 let r = admin
297 .get(&format!("{}?action=preview", root_path("config.json")))
298 .await;
299 assert_eq!(r.status, StatusCode::OK);
300 assert!(
301 r.header("content-disposition")
302 .unwrap()
303 .starts_with("inline;")
304 );
305 assert_eq!(r.body, b"{\"k\": 1}");
306 assert_eq!(
307 admin
308 .get(&format!("{}?action=preview", root_path("docs")))
309 .await
310 .status,
311 StatusCode::BAD_REQUEST
312 );
313}
314
315#[tokio::test]
316async fn content_action_serves_raw_bytes_with_mtime() {
317 let env = Env::new().await;
318 let admin = env.admin().await;
319 let r = admin
320 .get(&format!("{}?action=content", root_path("notes.md")))
321 .await;
322 assert_eq!(r.status, StatusCode::OK);
323 assert_eq!(
324 r.header("content-type").as_deref(),
325 Some("text/plain; charset=utf-8")
326 );
327 let mtime = r.header("x-file-mtime").unwrap();
328 assert!(mtime.parse::<i64>().is_ok());
329 assert_eq!(r.body, b"# notes");
330 assert_eq!(
331 admin
332 .get(&format!("{}?action=content", root_path("docs")))
333 .await
334 .status,
335 StatusCode::BAD_REQUEST
336 );
337}
338
339#[tokio::test]
340async fn content_is_capped_at_two_mibibytes() {
341 let env = Env::new().await;
342 let admin = env.admin().await;
343 let big = vec![b'x'; 2 * 1024 * 1024 + 1];
344 std::fs::write(env.file("big.bin"), &big).unwrap();
345 let r = admin
346 .get(&format!("{}?action=content", root_path("big.bin")))
347 .await;
348 assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
349 // The file itself still downloads fine.
350 let r = admin
351 .get(&format!("{}?action=download", root_path("big.bin")))
352 .await;
353 assert_eq!(r.status, StatusCode::OK);
354 assert_eq!(r.body.len(), big.len());
355}
356
357#[tokio::test]
358async fn editor_save_over_two_mibibytes_is_rejected_with_the_localized_error() {
359 let env = Env::new().await;
360 let admin = env.admin().await;
361 let big = vec![b'x'; 2 * 1024 * 1024 + 1];
362 let r = admin
363 .put_content(
364 &format!("{}?action=content", root_path("editme.txt")),
365 &big,
366 None,
367 )
368 .await;
369 assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
370 assert_eq!(r.json()["code"], "err_too_large_save");
371 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v1");
372}
373
374#[tokio::test]
375async fn editor_save_round_trip_and_conflict() {
376 let env = Env::new().await;
377 let admin = env.admin().await;
378 let path = format!("{}?action=content", root_path("editme.txt"));
379
380 // Read current mtime via the content endpoint.
381 let r = admin.get(&path).await;
382 assert_eq!(r.status, StatusCode::OK);
383 let mtime: i64 = r.header("x-file-mtime").unwrap().parse().unwrap();
384
385 // Save with a matching expected mtime.
386 let r = admin.put_content(&path, b"v2", Some(mtime)).await;
387 assert_eq!(r.status, StatusCode::OK);
388 let new_mtime = r.json()["mtime"].as_i64().unwrap();
389 assert!(new_mtime >= mtime);
390 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2");
391
392 // A stale/wrong expected mtime conflicts (409). Use a value far from the
393 // current mtime so this is deterministic regardless of the filesystem's
394 // timestamp granularity (the mtime may not have advanced after the save).
395 let r = admin.put_content(&path, b"v3", Some(mtime + 999_999)).await;
396 assert_eq!(r.status, StatusCode::CONFLICT);
397 // A conflict must not modify the file.
398 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2");
399
400 // No expected mtime → force save.
401 let r = admin.put_content(&path, b"v4", None).await;
402 assert_eq!(r.status, StatusCode::OK);
403 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v4");
404
405 // Saving a missing file → 404; a directory → 400.
406 // (PUT without action=content → 400.)
407 let r = admin
408 .raw(
409 axum::http::Method::PUT,
410 &root_path("editme.txt"),
411 &[("content-type", "text/plain")],
412 b"x".to_vec(),
413 )
414 .await;
415 assert_eq!(r.status, StatusCode::BAD_REQUEST);
416
417 let r = admin
418 .put_content(
419 &format!("{}?action=content", root_path("ghost.txt")),
420 b"x",
421 None,
422 )
423 .await;
424 assert_eq!(r.status, StatusCode::NOT_FOUND);
425 let r = admin
426 .put_content(&format!("{}?action=content", root_path("docs")), b"x", None)
427 .await;
428 assert_eq!(r.status, StatusCode::BAD_REQUEST);
429
430 // Oversized body → 413.
431 let r = admin
432 .put_content(&path, &vec![b'a'; 2 * 1024 * 1024 + 1], None)
433 .await;
434 assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
435}
436
437#[tokio::test]
438async fn mkdir_and_rename() {
439 let env = Env::new().await;
440 let admin = env.admin().await;
441
442 // mkdir names itself with ?action=mkdir.
443 let mkdir_url = |name: &str| format!("{}?action=mkdir", root_path(name));
444 let r = admin
445 .raw(
446 axum::http::Method::POST,
447 &mkdir_url("newdir"),
448 &[],
449 Vec::new(),
450 )
451 .await;
452 assert_eq!(r.status, StatusCode::OK);
453 assert!(env.file("newdir").is_dir());
454 // Duplicate → 409.
455 let r = admin
456 .raw(
457 axum::http::Method::POST,
458 &mkdir_url("newdir"),
459 &[],
460 Vec::new(),
461 )
462 .await;
463 assert_eq!(r.status, StatusCode::CONFLICT);
464 // Empty name → 400 (bare root POST with JSON op is rejected too).
465 let r = admin
466 .raw(axum::http::Method::POST, &mkdir_url(""), &[], Vec::new())
467 .await;
468 assert_eq!(r.status, StatusCode::BAD_REQUEST);
469 // A POST that names no action and carries no known body type is rejected
470 // instead of silently creating a folder.
471 let r = admin
472 .raw(
473 axum::http::Method::POST,
474 &root_path("sneaky"),
475 &[],
476 Vec::new(),
477 )
478 .await;
479 assert_eq!(r.status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
480 assert!(!env.file("sneaky").exists());
481
482 // Rename.
483 let r = admin
484 .post_json(
485 &root_path("editme.txt"),
486 &json!({ "op": "rename", "new_name": "renamed.txt" }),
487 )
488 .await;
489 assert_eq!(r.status, StatusCode::OK);
490 assert!(env.file("renamed.txt").exists());
491 // Conflict.
492 let r = admin
493 .post_json(
494 &root_path("renamed.txt"),
495 &json!({ "op": "rename", "new_name": "config.json" }),
496 )
497 .await;
498 assert_eq!(r.status, StatusCode::CONFLICT);
499 // With overwrite.
500 let r = admin
501 .post_json(
502 &root_path("renamed.txt"),
503 &json!({ "op": "rename", "new_name": "config.json", "overwrite": true }),
504 )
505 .await;
506 assert_eq!(r.status, StatusCode::OK);
507 assert_eq!(std::fs::read(env.file("config.json")).unwrap(), b"v1");
508 // Invalid name.
509 let r = admin
510 .post_json(
511 &root_path("notes.md"),
512 &json!({ "op": "rename", "new_name": "a/b" }),
513 )
514 .await;
515 assert_eq!(r.status, StatusCode::BAD_REQUEST);
516 // Missing source.
517 let r = admin
518 .post_json(
519 &root_path("ghost"),
520 &json!({ "op": "rename", "new_name": "x" }),
521 )
522 .await;
523 assert_eq!(r.status, StatusCode::NOT_FOUND);
524 // Unknown op: `api_types::Op` has no such variant, so the body fails to
525 // deserialize. `dispatch_inner` parses it itself, so this stays a 400.
526 let r = admin
527 .post_json(&root_path("notes.md"), &json!({ "op": "explode" }))
528 .await;
529 assert_eq!(r.status, StatusCode::BAD_REQUEST);
530}
531
532#[tokio::test]
533async fn move_and_copy_across_dirs() {
534 let env = Env::new().await;
535 let admin = env.admin().await;
536
537 // Move notes.md into docs/.
538 let r = admin
539 .post_json(
540 &root_path("notes.md"),
541 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }),
542 )
543 .await;
544 assert_eq!(r.status, StatusCode::OK);
545 assert!(!env.file("notes.md").exists());
546 assert_eq!(
547 std::fs::read(env.file("docs/notes.md")).unwrap(),
548 b"# notes"
549 );
550
551 // Copy docs/inner back out — as a folder.
552 let r = admin
553 .post_json(
554 &root_path("docs/inner"),
555 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
556 )
557 .await;
558 assert_eq!(r.status, StatusCode::OK);
559 assert_eq!(
560 std::fs::read(env.file("src/inner/hello.txt")).unwrap(),
561 b"hello world"
562 );
563 assert!(env.file("docs/inner/hello.txt").exists());
564
565 // Conflict without overwrite, ok with: copy into a folder that already
566 // holds a file with the same name.
567 let r = admin
568 .post_json(
569 &root_path("docs/a.txt"),
570 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
571 )
572 .await;
573 assert_eq!(r.status, StatusCode::OK);
574 assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a");
575 std::fs::write(env.file("docs/a.txt"), "file a2").unwrap();
576 let r = admin
577 .post_json(
578 &root_path("docs/a.txt"),
579 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
580 )
581 .await;
582 assert_eq!(r.status, StatusCode::CONFLICT);
583 let r = admin
584 .post_json(
585 &root_path("docs/a.txt"),
586 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src", "overwrite": true }),
587 )
588 .await;
589 assert_eq!(r.status, StatusCode::OK);
590 assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a2");
591
592 // Copying an item into its own folder (same path) is a no-op success.
593 let r = admin
594 .post_json(
595 &root_path("docs/a.txt"),
596 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "docs" }),
597 )
598 .await;
599 assert_eq!(r.status, StatusCode::OK);
600
601 // Moving a folder into itself → 400.
602 let r = admin
603 .post_json(
604 &root_path("docs"),
605 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }),
606 )
607 .await;
608 assert_eq!(r.status, StatusCode::BAD_REQUEST);
609
610 // Missing dst_root_id / dst dir.
611 let r = admin
612 .post_json(&root_path("docs/a.txt"), &json!({ "op": "move" }))
613 .await;
614 assert_eq!(r.status, StatusCode::BAD_REQUEST);
615 let r = admin
616 .post_json(
617 &root_path("docs/a.txt"),
618 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "no-such-dir" }),
619 )
620 .await;
621 assert_eq!(r.status, StatusCode::NOT_FOUND);
622}
623
624#[tokio::test]
625async fn delete_file_and_folder() {
626 let env = Env::new().await;
627 let admin = env.admin().await;
628
629 let r = admin.delete(&root_path("editme.txt")).await;
630 assert_eq!(r.status, StatusCode::OK);
631 assert!(!env.file("editme.txt").exists());
632
633 let r = admin.delete(&root_path("docs")).await;
634 assert_eq!(r.status, StatusCode::OK);
635 assert!(!env.file("docs").exists());
636
637 // Missing → 404. A DELETE on the bare root matches no route's method →
638 // 405 (the path only has GET/POST routes).
639 assert_eq!(
640 admin.delete(&root_path("ghost")).await.status,
641 StatusCode::NOT_FOUND
642 );
643 assert_eq!(
644 admin.delete("/api/files/1").await.status,
645 StatusCode::METHOD_NOT_ALLOWED
646 );
647 // DELETE with a trailing-slash root matches no route at all → 404 via
648 // the SPA fallback's API guard.
649 let r = admin.delete("/api/files/1/").await;
650 assert_eq!(r.status, StatusCode::NOT_FOUND);
651 assert_eq!(r.text(), "unknown endpoint");
652}
653
654#[tokio::test]
655async fn upload_creates_files_and_folders() {
656 let env = Env::new().await;
657 let admin = env.admin().await;
658
659 // Single file into the root, nested part name creates the folder.
660 let r = admin
661 .post_multipart(
662 &root_path(""),
663 &[("docs/uploaded.txt", b"up1"), ("new/nested.txt", b"up2")],
664 "",
665 )
666 .await;
667 assert_eq!(r.status, StatusCode::OK);
668 assert_eq!(
669 std::fs::read(env.file("docs/uploaded.txt")).unwrap(),
670 b"up1"
671 );
672 assert_eq!(std::fs::read(env.file("new/nested.txt")).unwrap(), b"up2");
673
674 // Conflict: existing file, no overwrite → 409 with the skipped list.
675 let r = admin
676 .post_multipart(&root_path(""), &[("docs/uploaded.txt", b"again")], "")
677 .await;
678 assert_eq!(r.status, StatusCode::CONFLICT);
679 assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"]));
680 assert_eq!(
681 std::fs::read(env.file("docs/uploaded.txt")).unwrap(),
682 b"up1"
683 );
684
685 // Mixed: one conflict + one new file → 409, the new one is uploaded.
686 let r = admin
687 .post_multipart(
688 &root_path(""),
689 &[("docs/uploaded.txt", b"again"), ("fresh.txt", b"new")],
690 "",
691 )
692 .await;
693 assert_eq!(r.status, StatusCode::CONFLICT);
694 assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"]));
695 assert_eq!(r.json()["uploaded"], 1);
696 assert_eq!(std::fs::read(env.file("fresh.txt")).unwrap(), b"new");
697
698 // overwrite=true replaces.
699 let r = admin
700 .post_multipart(
701 &root_path(""),
702 &[("docs/uploaded.txt", b"v3")],
703 "overwrite=true",
704 )
705 .await;
706 assert_eq!(r.status, StatusCode::OK);
707 assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"v3");
708
709 // A part name that is an existing directory → 409, and it is named in
710 // `skipped` so the client can fail just that file. Also with
711 // overwrite=true: a folder is never replaced by a file.
712 for query in ["", "overwrite=true"] {
713 let r = admin
714 .post_multipart(
715 &root_path(""),
716 &[("new", b"dir?"), ("beside.txt", b"ok")],
717 query,
718 )
719 .await;
720 assert_eq!(r.status, StatusCode::CONFLICT);
721 assert_eq!(r.json()["skipped"], json!(["new"]));
722 assert!(env.file("new").is_dir());
723 std::fs::remove_file(env.file("beside.txt")).unwrap();
724 }
725
726 // A quote in the part name: the client percent-escapes it, the server
727 // decodes it back (multer only unescapes backslashes).
728 let r = admin
729 .post_multipart(&root_path(""), &[("qu%22ote.txt", b"q")], "")
730 .await;
731 assert_eq!(r.status, StatusCode::OK);
732 assert_eq!(std::fs::read(env.file("qu\"ote.txt")).unwrap(), b"q");
733
734 // Path traversal in a part name → 400.
735 let r = admin
736 .post_multipart(&root_path(""), &[("../evil.txt", b"x")], "")
737 .await;
738 assert!(matches!(
739 r.status,
740 StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN
741 ));
742 assert!(!env.file("../evil.txt").exists());
743 assert!(!env.root.path().parent().unwrap().join("evil.txt").exists());
744
745 // No parts at all → 400.
746 let (ct, body) = multipart_body(&[], "b");
747 let r = admin
748 .raw(
749 axum::http::Method::POST,
750 &root_path(""),
751 &[("content-type", &ct)],
752 body,
753 )
754 .await;
755 assert_eq!(r.status, StatusCode::BAD_REQUEST);
756}
757
758#[cfg(unix)]
759#[tokio::test]
760async fn upload_does_not_follow_symlinked_directories_out_of_the_root() {
761 let env = Env::new().await;
762 let admin = env.admin().await;
763 let outside = tempfile::tempdir().unwrap();
764 std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap();
765
766 // Into the linked directory itself, and into a new folder below it.
767 for part in ["link/escaped.txt", "link/deeper/escaped.txt"] {
768 let r = admin
769 .post_multipart(&root_path("docs"), &[(part, b"leak")], "")
770 .await;
771 assert_eq!(r.status, StatusCode::FORBIDDEN, "{part}: {}", r.text());
772 }
773 assert!(!outside.path().join("escaped.txt").exists());
774 assert!(!outside.path().join("deeper").exists());
775 assert!(
776 std::fs::read_dir(outside.path()).unwrap().next().is_none(),
777 "no temp file may be left outside the root"
778 );
779
780 // A symlink that stays inside the root still works.
781 std::os::unix::fs::symlink(env.file("src"), env.file("docs/inside")).unwrap();
782 let r = admin
783 .post_multipart(&root_path("docs"), &[("inside/ok.txt", b"fine")], "")
784 .await;
785 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
786 assert_eq!(std::fs::read(env.file("src/ok.txt")).unwrap(), b"fine");
787}
788
789#[tokio::test]
790async fn exists_check_reports_targets_without_creating_anything() {
791 let env = Env::new().await;
792 let admin = env.admin().await;
793 let url = format!("{}?action=exists", root_path(""));
794
795 let r = admin
796 .post_json(
797 &url,
798 &json!({ "paths": [
799 "docs/a.txt",
800 "docs",
801 "missing.txt",
802 "nowhere/deep/file.txt",
803 ] }),
804 )
805 .await;
806 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
807 assert_eq!(
808 r.json()["existing"],
809 json!([
810 { "path": "docs/a.txt", "is_dir": false },
811 { "path": "docs", "is_dir": true },
812 ])
813 );
814 assert!(
815 !env.file("nowhere").exists(),
816 "the check must not create parent folders"
817 );
818
819 // Traversal → 400.
820 let r = admin
821 .post_json(&url, &json!({ "paths": ["../evil.txt"] }))
822 .await;
823 assert_eq!(r.status, StatusCode::BAD_REQUEST);
824
825 // Read-only roots cannot be uploaded to, so they cannot be checked either.
826 create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await;
827 let carol = login(&env, "carol", "carolpass1").await;
828 let carol_root = carol.get("/api/auth/me").await.json()["roots"][0]["id"]
829 .as_i64()
830 .unwrap();
831 let r = carol
832 .post_json(
833 &format!("/api/files/{carol_root}?action=exists"),
834 &json!({ "paths": ["a.txt"] }),
835 )
836 .await;
837 assert_eq!(r.status, StatusCode::FORBIDDEN);
838}
839
840#[cfg(unix)]
841#[tokio::test]
842async fn exists_check_does_not_follow_symlinked_directories_out_of_the_root() {
843 let env = Env::new().await;
844 let admin = env.admin().await;
845 let outside = tempfile::tempdir().unwrap();
846 std::fs::write(outside.path().join("secret.txt"), b"s").unwrap();
847 std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap();
848
849 for part in ["link/secret.txt", "link/deeper/x.txt"] {
850 let r = admin
851 .post_json(
852 &format!("{}?action=exists", root_path("docs")),
853 &json!({ "paths": [part] }),
854 )
855 .await;
856 assert_eq!(r.status, StatusCode::FORBIDDEN, "{part}: {}", r.text());
857 }
858 assert!(!outside.path().join("deeper").exists());
859}
860
861/// A complete multipart body for one part, streamed in two halves. `between`
862/// runs after the first half reached the server and before the second is
863/// sent, so the test can change the disk while the upload is in flight.
864async fn upload_in_two_halves(
865 admin: &Client,
866 name: &str,
867 between: impl FnOnce() + Send + 'static,
868) -> (StatusCode, serde_json::Value) {
869 let mut body: Vec<u8> = Vec::new();
870 body.extend_from_slice(
871 format!("--B\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
872 );
873 body.extend_from_slice(&vec![b'x'; 300 * 1024]);
874 body.extend_from_slice(b"\r\n--B--\r\n");
875 let half = body.len() / 2;
876 let second: Vec<u8> = body.split_off(half);
877 // Three steps: first half, the side effect, second half.
878 let steps: Vec<Box<dyn FnOnce() -> Option<Vec<u8>> + Send>> = vec![
879 Box::new(move || Some(body)),
880 Box::new(move || {
881 between();
882 None
883 }),
884 Box::new(move || Some(second)),
885 ];
886 let stream = futures_util::stream::unfold(steps.into_iter(), |mut it| async move {
887 loop {
888 let step = it.next()?;
889 match step() {
890 Some(chunk) => {
891 return Some((Ok::<_, std::io::Error>(axum::body::Bytes::from(chunk)), it));
892 }
893 // Let the server consume the first half before continuing.
894 None => tokio::task::yield_now().await,
895 }
896 }
897 });
898 let ct = [("content-type", "multipart/form-data; boundary=B")];
899 let body = axum::body::Body::from_stream(stream);
900 let r = admin
901 .raw(axum::http::Method::POST, &root_path(""), &ct, body)
902 .await;
903 (
904 r.status,
905 serde_json::from_slice(&r.body).unwrap_or(json!(null)),
906 )
907}
908
909/// The pre-upload stat said "does not exist". A file created while the body
910/// streams in must still not be replaced: the publish step checks again,
911/// atomically.
912#[tokio::test]
913async fn upload_does_not_clobber_a_file_created_during_the_transfer() {
914 let env = Env::new().await;
915 let admin = env.admin().await;
916 let target = env.file("raced.txt");
917 assert!(!target.exists());
918
919 let t = target.clone();
920 let (status, body) = upload_in_two_halves(&admin, "raced.txt", move || {
921 std::fs::write(&t, b"someone else").unwrap();
922 })
923 .await;
924 assert_eq!(status, StatusCode::CONFLICT, "{body}");
925 assert_eq!(body["skipped"], json!(["raced.txt"]));
926 assert_eq!(std::fs::read(&target).unwrap(), b"someone else");
927 assert!(
928 !entries(&env).iter().any(|n| n.starts_with(".upload-")),
929 "scratch file left behind: {:?}",
930 entries(&env)
931 );
932}
933
934/// A multipart body that stops inside a part: the headers and part of the
935/// payload, then end of stream with no closing boundary. That is what reaches
936/// the server when the user closes the tab or the connection drops.
937async fn upload_stopped_mid_part(admin: &Client) -> StatusCode {
938 let mut body: Vec<u8> = Vec::new();
939 body.extend_from_slice(
940 b"--B\r\nContent-Disposition: form-data; name=\"interrupted.txt\"\r\n\r\n",
941 );
942 body.extend_from_slice(&vec![b'x'; 300 * 1024]);
943 let stream = futures_util::stream::unfold(body, |mut rest| async move {
944 if rest.len() > 1024 {
945 let n = rest.len() / 2;
946 let chunk: Vec<u8> = rest.drain(..n).collect();
947 Some((
948 Ok::<_, std::io::Error>(axum::body::Bytes::from(chunk)),
949 rest,
950 ))
951 } else {
952 None // EOF: the terminating boundary never arrives
953 }
954 });
955 let ct = [("content-type", "multipart/form-data; boundary=B")];
956 let body = axum::body::Body::from_stream(stream);
957 admin
958 .raw(axum::http::Method::POST, &root_path(""), &ct, body)
959 .await
960 .status
961}
962
963/// Every entry name in the server root, hidden ones included.
964fn entries(env: &Env) -> Vec<String> {
965 std::fs::read_dir(env.root.path())
966 .unwrap()
967 .flatten()
968 .map(|e| e.file_name().to_string_lossy().into_owned())
969 .collect()
970}
971
972/// An upload is streamed to `.upload-<token>` and renamed into place. A part
973/// that never reaches the rename must take the scratch file with it. Nothing
974/// ever names that file again, and listings show it.
975#[tokio::test]
976async fn an_interrupted_upload_leaves_no_scratch_file() {
977 let env = Env::new().await;
978 let admin = env.admin().await;
979
980 let status = upload_stopped_mid_part(&admin).await;
981 assert!(
982 status.is_client_error(),
983 "expected a rejection, got {status}"
984 );
985 assert!(
986 !entries(&env).iter().any(|n| n.starts_with(".upload-")),
987 "scratch file left behind: {:?}",
988 entries(&env)
989 );
990 assert!(!env.file("interrupted.txt").exists());
991
992 // The same assertion after a completed upload, so a guard that never
993 // disarms cannot pass this test by accident.
994 let r = admin
995 .post_multipart(&root_path(""), &[("finished.txt", b"whole")], "")
996 .await;
997 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
998 assert_eq!(std::fs::read(env.file("finished.txt")).unwrap(), b"whole");
999 assert!(
1000 !entries(&env).iter().any(|n| n.starts_with(".upload-")),
1001 "scratch file left after a completed upload: {:?}",
1002 entries(&env)
1003 );
1004}
1005
1006#[tokio::test]
1007async fn read_only_root_blocks_writes_but_allows_reads() {
1008 let env = Env::new().await;
1009 let admin = env.admin().await;
1010 create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await;
1011 let carol = login(&env, "carol", "carolpass1").await;
1012 let carol_root_id = carol.get("/api/auth/me").await.json()["roots"][0]["id"]
1013 .as_i64()
1014 .unwrap();
1015
1016 // Reads work.
1017 let r = carol.get(&format!("/api/files/{carol_root_id}")).await;
1018 assert_eq!(r.status, StatusCode::OK);
1019 assert!(!r.json()["entries"].as_array().unwrap().is_empty());
1020 let r = carol
1021 .get(&format!("/api/files/{carol_root_id}/a.txt?action=download"))
1022 .await;
1023 assert_eq!(r.body, b"file a");
1024
1025 // Writes are blocked.
1026 let base = format!("/api/files/{carol_root_id}/x?action=mkdir");
1027 assert_eq!(
1028 carol
1029 .raw(axum::http::Method::POST, &base, &[], Vec::new())
1030 .await
1031 .status,
1032 StatusCode::FORBIDDEN
1033 );
1034 assert_eq!(
1035 carol
1036 .delete(&format!("/api/files/{carol_root_id}/a.txt"))
1037 .await
1038 .status,
1039 StatusCode::FORBIDDEN
1040 );
1041 assert_eq!(
1042 carol
1043 .post_json(
1044 &format!("/api/files/{carol_root_id}/a.txt"),
1045 &json!({ "op": "rename", "new_name": "b.txt" })
1046 )
1047 .await
1048 .status,
1049 StatusCode::FORBIDDEN
1050 );
1051}
1052
1053#[tokio::test]
1054async fn user_cannot_touch_foreign_root() {
1055 let env = Env::new().await;
1056 let admin = env.admin().await;
1057 create_user(&admin, "dave", "davepass12", &[("src", "rw")]).await;
1058 let dave = login(&env, "dave", "davepass12").await;
1059 let dave_root_id = dave.get("/api/auth/me").await.json()["roots"][0]["id"]
1060 .as_i64()
1061 .unwrap();
1062
1063 // His own root works.
1064 assert_eq!(
1065 dave.get(&format!("/api/files/{dave_root_id}")).await.status,
1066 StatusCode::OK
1067 );
1068 // The admin's root id (1) is not his → 403.
1069 assert_eq!(dave.get("/api/files/1").await.status, StatusCode::FORBIDDEN);
1070 // Writing into a root he doesn't have → 403.
1071 assert_eq!(
1072 dave.raw(
1073 axum::http::Method::POST,
1074 "/api/files/1/evil?action=mkdir",
1075 &[],
1076 Vec::new()
1077 )
1078 .await
1079 .status,
1080 StatusCode::FORBIDDEN
1081 );
1082}
1083
1084/// Listings report a content-sniffed `kind`, not an extension guess.
1085#[tokio::test]
1086async fn listing_reports_sniffed_kinds() {
1087 let env = Env::new().await;
1088 let admin = env.admin().await;
1089 // A PNG named .txt and a text file named .png: the bytes must win.
1090 std::fs::write(
1091 env.file("lies.txt"),
1092 [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
1093 )
1094 .unwrap();
1095 std::fs::write(env.file("lies.png"), "just words\n").unwrap();
1096 std::fs::write(env.file("report.html"), "<!doctype html><p>hi").unwrap();
1097 std::fs::write(env.file("noext"), "plain text, no extension\n").unwrap();
1098
1099 let r = admin.get(&root_path("")).await;
1100 assert_eq!(r.status, StatusCode::OK);
1101 let j = r.json();
1102 let kind = |name: &str| -> String {
1103 j["entries"]
1104 .as_array()
1105 .unwrap()
1106 .iter()
1107 .find(|e| e["name"] == name)
1108 .unwrap_or_else(|| panic!("{name} missing from listing"))["kind"]
1109 .as_str()
1110 .unwrap()
1111 .to_string()
1112 };
1113 assert_eq!(kind("lies.txt"), "image");
1114 assert_eq!(kind("lies.png"), "text");
1115 assert_eq!(kind("report.html"), "text");
1116 assert_eq!(kind("noext"), "text");
1117 assert_eq!(kind("blob.bin"), "binary");
1118 assert_eq!(kind("docs"), "dir");
1119 assert_eq!(kind("config.json"), "text");
1120}
1121
1122/// A file the browser would parse as a document is served sandboxed, so it
1123/// can render as a page without being able to act as the app. Scriptable
1124/// files are never frameable; non-scriptable previews are frameable by the
1125/// app itself only.
1126#[tokio::test]
1127async fn scriptable_files_are_served_sandboxed() {
1128 let env = Env::new().await;
1129 let admin = env.admin().await;
1130 std::fs::write(env.file("page.html"), "<!doctype html><p>hi").unwrap();
1131 std::fs::write(
1132 env.file("logo.svg"),
1133 "<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
1134 )
1135 .unwrap();
1136
1137 for name in ["page.html", "logo.svg"] {
1138 let r = admin
1139 .get(&format!("{}?action=preview", root_path(name)))
1140 .await;
1141 assert_eq!(r.status, StatusCode::OK);
1142 let csp = r.header("content-security-policy").unwrap();
1143 assert!(csp.contains("sandbox "), "{name} not sandboxed: {csp}");
1144 assert!(csp.contains("allow-scripts"), "{name}: {csp}");
1145 // The whole security property: an opaque origin.
1146 assert!(
1147 !csp.contains("allow-same-origin"),
1148 "{name} must never get allow-same-origin: {csp}"
1149 );
1150 assert!(
1151 !csp.contains("allow-top-navigation ") && !csp.contains("allow-popups-to-escape"),
1152 "{name}: {csp}"
1153 );
1154 // Still rendered as a document, not downloaded.
1155 assert!(
1156 r.header("content-disposition")
1157 .unwrap()
1158 .starts_with("inline")
1159 );
1160 // Never frameable: same-origin framing would give its JS access to
1161 // the app.
1162 assert!(
1163 csp.contains("frame-ancestors 'none'"),
1164 "{name} must never be frameable: {csp}"
1165 );
1166 assert_eq!(
1167 r.header("x-frame-options").as_deref(),
1168 Some("DENY"),
1169 "{name}"
1170 );
1171 }
1172
1173 // A non-scriptable preview is frameable by the app itself only.
1174 let r = admin
1175 .get(&format!("{}?action=preview", root_path("blob.bin")))
1176 .await;
1177 let csp = r.header("content-security-policy").unwrap();
1178 assert!(!csp.contains("sandbox"), "{csp}");
1179 assert!(
1180 csp.contains("frame-ancestors 'self'"),
1181 "preview must be frameable same-origin: {csp}"
1182 );
1183 assert_eq!(r.header("x-frame-options").as_deref(), Some("SAMEORIGIN"));
1184
1185 // The same file as a *download* keeps the app policy (unframeable).
1186 let r = admin
1187 .get(&format!("{}?action=download", root_path("blob.bin")))
1188 .await;
1189 let csp = r.header("content-security-policy").unwrap();
1190 assert!(
1191 csp.contains("frame-ancestors 'none'"),
1192 "download must keep the app policy: {csp}"
1193 );
1194 assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY"));
1195
1196 // And the app's own pages are untouched by the `if_not_present` switch.
1197 let r = admin.get("/").await;
1198 let csp = r.header("content-security-policy").unwrap();
1199 assert!(
1200 csp.contains("wasm-unsafe-eval") && !csp.contains("sandbox"),
1201 "{csp}"
1202 );
1203 assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY"));
1204}
1205
1206#[tokio::test]
1207async fn archive_does_not_follow_symlinks_out_of_the_root() {
1208 let env = Env::new().await;
1209 let admin = env.admin().await;
1210
1211 // A directory outside the served root, linked to from inside it.
1212 let outside = tempfile::tempdir().unwrap();
1213 std::fs::write(outside.path().join("secret.txt"), "leaked").unwrap();
1214 std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap();
1215
1216 let r = admin
1217 .get(&format!("{}?action=download&format=tar", root_path("docs")))
1218 .await;
1219 assert_eq!(r.status, StatusCode::OK);
1220 let map = tar_map(&r.body, Compress::None);
1221 assert!(
1222 !map.keys().any(|k| k.contains("secret.txt")),
1223 "archive escaped the root: {:?}",
1224 map.keys().collect::<Vec<_>>()
1225 );
1226 // The legitimate entries are still there.
1227 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
1228 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
1229}
1230
1231#[tokio::test]
1232async fn listing_is_paged_in_the_requested_order() {
1233 let env = Env::new().await;
1234 let admin = env.admin().await;
1235
1236 let dir = env.file("paged");
1237 std::fs::create_dir_all(dir.join("sub")).unwrap();
1238 for i in 0..25 {
1239 std::fs::write(dir.join(format!("f{i:02}")), vec![b'x'; i]).unwrap();
1240 }
1241 let names = |j: &serde_json::Value| -> Vec<String> {
1242 j["entries"]
1243 .as_array()
1244 .unwrap()
1245 .iter()
1246 .map(|e| e["name"].as_str().unwrap().to_string())
1247 .collect()
1248 };
1249
1250 // No params: the whole folder by name, folders first.
1251 let j = admin.get(&root_path("paged")).await.json();
1252 assert_eq!(
1253 (j["total"].as_u64(), j["offset"].as_u64()),
1254 (Some(26), Some(0))
1255 );
1256 assert_eq!(names(&j).len(), 26);
1257 assert_eq!(names(&j)[0], "sub");
1258
1259 let r = admin
1260 .get(&format!(
1261 "{}?sort=size&desc=true&offset=10&limit=10",
1262 root_path("paged")
1263 ))
1264 .await;
1265 assert_eq!(r.status, StatusCode::OK);
1266 let j = r.json();
1267 assert_eq!(
1268 (j["total"].as_u64(), j["offset"].as_u64()),
1269 (Some(26), Some(10))
1270 );
1271 // Index 0 is "sub", then f24 down to f00.
1272 let want: Vec<String> = (6..=15).rev().map(|i| format!("f{i:02}")).collect();
1273 assert_eq!(names(&j), want);
1274
1275 // An offset past the end returns the last page.
1276 let j = admin
1277 .get(&format!("{}?offset=999&limit=10", root_path("paged")))
1278 .await
1279 .json();
1280 assert_eq!(j["offset"].as_u64(), Some(20));
1281 assert_eq!(names(&j).len(), 6);
1282
1283 let j = admin
1284 .get(&format!("{}?dirs=true", root_path("paged")))
1285 .await
1286 .json();
1287 assert_eq!(names(&j), ["sub"]);
1288 assert_eq!(j["total"].as_u64(), Some(1));
1289}
1290
1291#[tokio::test]
1292async fn download_revalidates_with_last_modified() {
1293 let env = Env::new().await;
1294 let admin = env.admin().await;
1295 let path = format!("{}?action=download", root_path("editme.txt"));
1296 let file = env.root.path().join("editme.txt");
1297
1298 // A file written in the last two seconds gets no validator. Pin the mtime
1299 // to "now" first: the fixture is written during `Env` setup, which under a
1300 // loaded parallel run can take longer than that two-second window.
1301 std::fs::File::options()
1302 .write(true)
1303 .open(&file)
1304 .unwrap()
1305 .set_modified(std::time::SystemTime::now())
1306 .unwrap();
1307 let r = admin.get(&path).await;
1308 assert_eq!(r.status, StatusCode::OK);
1309 assert!(r.header("last-modified").is_none());
1310 // Nor a 304, whatever date the client sends: a second write in the same
1311 // second would go unseen.
1312 let r = admin
1313 .raw(
1314 axum::http::Method::GET,
1315 &path,
1316 &[("if-modified-since", "Fri, 01 Jan 2100 00:00:00 GMT")],
1317 Vec::new(),
1318 )
1319 .await;
1320 assert_eq!(r.status, StatusCode::OK);
1321 assert_eq!(r.body, b"v1");
1322 // No validator here, so the policy matters more: with no Cache-Control a
1323 // shared cache may apply heuristic freshness.
1324 assert_eq!(
1325 r.header("cache-control").as_deref(),
1326 Some("private, no-cache"),
1327 "a file response always carries a caching policy"
1328 );
1329
1330 // Backdate the file so the validator appears.
1331 let f = std::fs::File::options().write(true).open(&file).unwrap();
1332 f.set_modified(
1333 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
1334 )
1335 .unwrap();
1336 let r = admin.get(&path).await;
1337 assert_eq!(r.status, StatusCode::OK);
1338 assert_eq!(
1339 r.header("cache-control").as_deref(),
1340 Some("private, no-cache")
1341 );
1342 let lm = r.header("last-modified").expect("Last-Modified header");
1343
1344 let r = admin
1345 .raw(
1346 axum::http::Method::GET,
1347 &path,
1348 &[("if-modified-since", lm.as_str())],
1349 Vec::new(),
1350 )
1351 .await;
1352 assert_eq!(r.status, StatusCode::NOT_MODIFIED);
1353 assert!(r.body.is_empty());
1354 // The refresh repeats the policy, so the stored entry does not lose it.
1355 assert_eq!(
1356 r.header("cache-control").as_deref(),
1357 Some("private, no-cache")
1358 );
1359}
1360
1361/// An mtime before 1970 has no HTTP date. The file is still served, whole
1362/// and without a validator.
1363#[tokio::test]
1364async fn file_dated_before_1970_is_served() {
1365 let env = Env::new().await;
1366 let admin = env.admin().await;
1367 std::fs::File::options()
1368 .write(true)
1369 .open(env.file("editme.txt"))
1370 .unwrap()
1371 .set_modified(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400))
1372 .unwrap();
1373 for action in ["download", "preview"] {
1374 let r = admin
1375 .raw(
1376 axum::http::Method::GET,
1377 &format!("{}?action={action}", root_path("editme.txt")),
1378 &[("range", "bytes=0-0")],
1379 Vec::new(),
1380 )
1381 .await;
1382 assert_eq!(r.status, StatusCode::OK, "{action}");
1383 assert_eq!(r.body, b"v1", "{action}");
1384 assert!(r.header("last-modified").is_none(), "{action}");
1385 assert_eq!(
1386 r.header("cache-control").as_deref(),
1387 Some("private, no-cache")
1388 );
1389 }
1390}
1391
1392/// The browser copies a 304's CSP onto the cached response, so a revalidated
1393/// file must keep the policy its 200 had, not get the app's.
1394#[tokio::test]
1395async fn revalidation_keeps_the_file_policy() {
1396 let env = Env::new().await;
1397 let admin = env.admin().await;
1398 std::fs::write(env.file("page.html"), "<!doctype html><p>hi").unwrap();
1399 for name in ["page.html", "blob.bin"] {
1400 std::fs::File::options()
1401 .write(true)
1402 .open(env.file(name))
1403 .unwrap()
1404 .set_modified(
1405 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
1406 )
1407 .unwrap();
1408 let path = format!("{}?action=preview", root_path(name));
1409 let first = admin.get(&path).await;
1410 let lm = first.header("last-modified").expect("Last-Modified header");
1411 let r = admin
1412 .raw(
1413 axum::http::Method::GET,
1414 &path,
1415 &[("if-modified-since", lm.as_str())],
1416 Vec::new(),
1417 )
1418 .await;
1419 assert_eq!(r.status, StatusCode::NOT_MODIFIED, "{name}");
1420 assert_eq!(
1421 r.header("content-security-policy"),
1422 first.header("content-security-policy"),
1423 "{name}"
1424 );
1425 assert_eq!(
1426 r.header("x-frame-options"),
1427 first.header("x-frame-options"),
1428 "{name}"
1429 );
1430 }
1431}
1432
1433// ---------------------------------------------------------------------------
1434// Symlinks: an operation on a name acts on the entry, not on what it points at
1435// ---------------------------------------------------------------------------
1436
1437/// Create `link` inside the root, pointing at `target`.
1438fn symlink(env: &Env, target: &std::path::Path, link: &str) {
1439 std::os::unix::fs::symlink(target, env.file(link)).unwrap();
1440}
1441
1442#[tokio::test]
1443async fn deleting_a_symlink_removes_the_link_not_its_target() {
1444 let env = Env::new().await;
1445 let admin = env.admin().await;
1446 symlink(&env, &env.file("notes.md"), "alias.md");
1447
1448 let r = admin.delete("/api/files/1/alias.md").await;
1449 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1450
1451 assert!(env.file("alias.md").symlink_metadata().is_err());
1452 assert_eq!(
1453 std::fs::read_to_string(env.file("notes.md")).unwrap(),
1454 "# notes",
1455 "the delete followed the link"
1456 );
1457}
1458
1459#[tokio::test]
1460async fn a_dangling_symlink_can_be_deleted() {
1461 let env = Env::new().await;
1462 let admin = env.admin().await;
1463 symlink(&env, &env.file("gone.txt"), "dangling.md");
1464
1465 // Resolving strictly reports "not found", which would leave the link
1466 // undeletable through the API.
1467 let r = admin.delete("/api/files/1/dangling.md").await;
1468 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1469 assert!(env.file("dangling.md").symlink_metadata().is_err());
1470}
1471
1472#[tokio::test]
1473async fn renaming_a_symlink_renames_the_link() {
1474 let env = Env::new().await;
1475 let admin = env.admin().await;
1476 symlink(&env, &env.file("docs/a.txt"), "alias.txt");
1477
1478 let r = admin
1479 .post_json(
1480 "/api/files/1/alias.txt",
1481 &json!({ "op": "rename", "new_name": "renamed.txt" }),
1482 )
1483 .await;
1484 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1485
1486 // The link moved. Following it would have renamed the target, and into
1487 // the target's own directory at that.
1488 assert!(
1489 env.file("renamed.txt")
1490 .symlink_metadata()
1491 .unwrap()
1492 .file_type()
1493 .is_symlink()
1494 );
1495 assert!(env.file("docs/a.txt").exists());
1496 assert!(!env.file("docs/renamed.txt").exists());
1497}
1498
1499#[tokio::test]
1500async fn moving_a_symlink_moves_the_link() {
1501 let env = Env::new().await;
1502 let admin = env.admin().await;
1503 symlink(&env, &env.file("notes.md"), "alias.md");
1504
1505 let r = admin
1506 .post_json(
1507 "/api/files/1/alias.md",
1508 &json!({ "op": "move", "dst_root_id": 1, "dst": "docs" }),
1509 )
1510 .await;
1511 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1512
1513 assert!(
1514 env.file("docs/alias.md")
1515 .symlink_metadata()
1516 .unwrap()
1517 .file_type()
1518 .is_symlink()
1519 );
1520 assert!(env.file("notes.md").exists(), "the move followed the link");
1521}
1522
1523#[tokio::test]
1524async fn copying_onto_a_symlink_replaces_it() {
1525 let env = Env::new().await;
1526 let admin = env.admin().await;
1527
1528 // A link inside the root aimed outside it. `std::fs::copy` follows a
1529 // destination symlink, so without unlinking it first the write lands
1530 // outside the root with every path check passing.
1531 let outside = env.root.path().parent().unwrap().join("outside.txt");
1532 std::fs::write(&outside, "SECRET").unwrap();
1533 std::fs::create_dir_all(env.file("dest")).unwrap();
1534 std::os::unix::fs::symlink(&outside, env.file("dest/notes.md")).unwrap();
1535
1536 let r = admin
1537 .post_json(
1538 "/api/files/1/notes.md",
1539 &json!({ "op": "copy", "dst_root_id": 1, "dst": "dest", "overwrite": true }),
1540 )
1541 .await;
1542 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1543
1544 assert_eq!(
1545 std::fs::read_to_string(&outside).unwrap(),
1546 "SECRET",
1547 "the copy escaped the root"
1548 );
1549 assert_eq!(
1550 std::fs::read_to_string(env.file("dest/notes.md")).unwrap(),
1551 "# notes"
1552 );
1553 assert!(
1554 !env.file("dest/notes.md")
1555 .symlink_metadata()
1556 .unwrap()
1557 .file_type()
1558 .is_symlink()
1559 );
1560}
1561
1562#[tokio::test]
1563async fn copying_a_symlink_copies_what_it_points_at() {
1564 let env = Env::new().await;
1565 let admin = env.admin().await;
1566 symlink(&env, &env.file("notes.md"), "alias.md");
1567 std::fs::create_dir_all(env.file("dest")).unwrap();
1568
1569 // The source is followed on purpose: a copy wants the bytes, like `cp`.
1570 let r = admin
1571 .post_json(
1572 "/api/files/1/alias.md",
1573 &json!({ "op": "copy", "dst_root_id": 1, "dst": "dest" }),
1574 )
1575 .await;
1576 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1577 assert_eq!(
1578 std::fs::read_to_string(env.file("dest/alias.md")).unwrap(),
1579 "# notes"
1580 );
1581}
1582
1583#[tokio::test]
1584async fn a_symlink_out_of_the_root_still_cannot_be_read_or_written() {
1585 let env = Env::new().await;
1586 let admin = env.admin().await;
1587 let outside = env.root.path().parent().unwrap().join("outside.txt");
1588 std::fs::write(&outside, "SECRET").unwrap();
1589 std::os::unix::fs::symlink(&outside, env.file("escape.txt")).unwrap();
1590
1591 // Reads and content writes do follow a link, so containment rests on
1592 // `ensure_within` rejecting one that leaves the root.
1593 let r = admin.get("/api/files/1/escape.txt?action=content").await;
1594 assert!(r.status.is_client_error(), "{}", r.status);
1595 assert_ne!(r.text(), "SECRET");
1596
1597 let r = admin
1598 .put_content("/api/files/1/escape.txt?action=content", b"payload", None)
1599 .await;
1600 assert!(r.status.is_client_error(), "{}", r.status);
1601 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "SECRET");
1602
1603 // Deleting the link is fine: that touches only the entry inside the root.
1604 let r = admin.delete("/api/files/1/escape.txt").await;
1605 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1606 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "SECRET");
1607}
1608