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 env: &Env,
866 admin: &Client,
867 name: &str,
868 between: impl FnOnce() + Send + 'static,
869) -> (StatusCode, serde_json::Value) {
870 let mut body: Vec<u8> = Vec::new();
871 body.extend_from_slice(
872 format!("--B\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
873 );
874 body.extend_from_slice(&vec![b'x'; 300 * 1024]);
875 body.extend_from_slice(b"\r\n--B--\r\n");
876 let half = body.len() / 2;
877 let second: Vec<u8> = body.split_off(half);
878 // Three steps: first half, the side effect, second half.
879 let steps: Vec<Box<dyn FnOnce() -> Option<Vec<u8>> + Send>> = vec![
880 Box::new(move || Some(body)),
881 Box::new(move || {
882 between();
883 None
884 }),
885 Box::new(move || Some(second)),
886 ];
887 let stream = futures_util::stream::unfold(steps.into_iter(), |mut it| async move {
888 loop {
889 let step = it.next()?;
890 match step() {
891 Some(chunk) => {
892 return Some((Ok::<_, std::io::Error>(axum::body::Bytes::from(chunk)), it));
893 }
894 // Let the server consume the first half before continuing.
895 None => tokio::task::yield_now().await,
896 }
897 }
898 });
899 let req = axum::http::Request::builder()
900 .method(axum::http::Method::POST)
901 .uri(root_path(""))
902 .header("content-type", "multipart/form-data; boundary=B")
903 .header(
904 "cookie",
905 format!("dovenest_session={}", admin.cookie.as_ref().unwrap()),
906 )
907 .body(axum::body::Body::from_stream(stream))
908 .unwrap();
909 let res = tower::ServiceExt::oneshot(env.app.clone(), req)
910 .await
911 .expect("request");
912 let status = res.status();
913 let bytes = http_body_util::BodyExt::collect(res.into_body())
914 .await
915 .unwrap()
916 .to_bytes();
917 (
918 status,
919 serde_json::from_slice(&bytes).unwrap_or(json!(null)),
920 )
921}
922
923/// The pre-upload stat said "does not exist". A file created while the body
924/// streams in must still not be replaced: the publish step checks again,
925/// atomically.
926#[tokio::test]
927async fn upload_does_not_clobber_a_file_created_during_the_transfer() {
928 let env = Env::new().await;
929 let admin = env.admin().await;
930 let target = env.file("raced.txt");
931 assert!(!target.exists());
932
933 let t = target.clone();
934 let (status, body) = upload_in_two_halves(&env, &admin, "raced.txt", move || {
935 std::fs::write(&t, b"someone else").unwrap();
936 })
937 .await;
938 assert_eq!(status, StatusCode::CONFLICT, "{body}");
939 assert_eq!(body["skipped"], json!(["raced.txt"]));
940 assert_eq!(std::fs::read(&target).unwrap(), b"someone else");
941 assert!(
942 !entries(&env).iter().any(|n| n.starts_with(".upload-")),
943 "scratch file left behind: {:?}",
944 entries(&env)
945 );
946}
947
948/// A multipart body that stops inside a part: the headers and part of the
949/// payload, then end of stream with no closing boundary. That is what reaches
950/// the server when the user closes the tab or the connection drops.
951async fn upload_stopped_mid_part(env: &Env, admin: &Client) -> StatusCode {
952 let mut body: Vec<u8> = Vec::new();
953 body.extend_from_slice(
954 b"--B\r\nContent-Disposition: form-data; name=\"interrupted.txt\"\r\n\r\n",
955 );
956 body.extend_from_slice(&vec![b'x'; 300 * 1024]);
957 let stream = futures_util::stream::unfold(body, |mut rest| async move {
958 if rest.len() > 1024 {
959 let n = rest.len() / 2;
960 let chunk: Vec<u8> = rest.drain(..n).collect();
961 Some((
962 Ok::<_, std::io::Error>(axum::body::Bytes::from(chunk)),
963 rest,
964 ))
965 } else {
966 None // EOF: the terminating boundary never arrives
967 }
968 });
969 let req = axum::http::Request::builder()
970 .method(axum::http::Method::POST)
971 .uri(root_path(""))
972 .header("content-type", "multipart/form-data; boundary=B")
973 .header(
974 "cookie",
975 format!("dovenest_session={}", admin.cookie.as_ref().unwrap()),
976 )
977 .body(axum::body::Body::from_stream(stream))
978 .unwrap();
979 let res = tower::ServiceExt::oneshot(env.app.clone(), req)
980 .await
981 .expect("request");
982 let status = res.status();
983 let _ = http_body_util::BodyExt::collect(res.into_body()).await;
984 status
985}
986
987/// Every entry name in the server root, hidden ones included.
988fn entries(env: &Env) -> Vec<String> {
989 std::fs::read_dir(env.root.path())
990 .unwrap()
991 .flatten()
992 .map(|e| e.file_name().to_string_lossy().into_owned())
993 .collect()
994}
995
996/// An upload is streamed to `.upload-<token>` and renamed into place. A part
997/// that never reaches the rename must take the scratch file with it. Nothing
998/// ever names that file again, and listings show it.
999#[tokio::test]
1000async fn an_interrupted_upload_leaves_no_scratch_file() {
1001 let env = Env::new().await;
1002 let admin = env.admin().await;
1003
1004 let status = upload_stopped_mid_part(&env, &admin).await;
1005 assert!(
1006 status.is_client_error(),
1007 "expected a rejection, got {status}"
1008 );
1009 assert!(
1010 !entries(&env).iter().any(|n| n.starts_with(".upload-")),
1011 "scratch file left behind: {:?}",
1012 entries(&env)
1013 );
1014 assert!(!env.file("interrupted.txt").exists());
1015
1016 // The same assertion after a completed upload, so a guard that never
1017 // disarms cannot pass this test by accident.
1018 let r = admin
1019 .post_multipart(&root_path(""), &[("finished.txt", b"whole")], "")
1020 .await;
1021 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1022 assert_eq!(std::fs::read(env.file("finished.txt")).unwrap(), b"whole");
1023 assert!(
1024 !entries(&env).iter().any(|n| n.starts_with(".upload-")),
1025 "scratch file left after a completed upload: {:?}",
1026 entries(&env)
1027 );
1028}
1029
1030#[tokio::test]
1031async fn read_only_root_blocks_writes_but_allows_reads() {
1032 let env = Env::new().await;
1033 let admin = env.admin().await;
1034 create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await;
1035 let carol = login(&env, "carol", "carolpass1").await;
1036 let carol_root_id = carol.get("/api/auth/me").await.json()["roots"][0]["id"]
1037 .as_i64()
1038 .unwrap();
1039
1040 // Reads work.
1041 let r = carol.get(&format!("/api/files/{carol_root_id}")).await;
1042 assert_eq!(r.status, StatusCode::OK);
1043 assert!(!r.json()["entries"].as_array().unwrap().is_empty());
1044 let r = carol
1045 .get(&format!("/api/files/{carol_root_id}/a.txt?action=download"))
1046 .await;
1047 assert_eq!(r.body, b"file a");
1048
1049 // Writes are blocked.
1050 let base = format!("/api/files/{carol_root_id}/x?action=mkdir");
1051 assert_eq!(
1052 carol
1053 .raw(axum::http::Method::POST, &base, &[], Vec::new())
1054 .await
1055 .status,
1056 StatusCode::FORBIDDEN
1057 );
1058 assert_eq!(
1059 carol
1060 .delete(&format!("/api/files/{carol_root_id}/a.txt"))
1061 .await
1062 .status,
1063 StatusCode::FORBIDDEN
1064 );
1065 assert_eq!(
1066 carol
1067 .post_json(
1068 &format!("/api/files/{carol_root_id}/a.txt"),
1069 &json!({ "op": "rename", "new_name": "b.txt" })
1070 )
1071 .await
1072 .status,
1073 StatusCode::FORBIDDEN
1074 );
1075}
1076
1077#[tokio::test]
1078async fn user_cannot_touch_foreign_root() {
1079 let env = Env::new().await;
1080 let admin = env.admin().await;
1081 create_user(&admin, "dave", "davepass12", &[("src", "rw")]).await;
1082 let dave = login(&env, "dave", "davepass12").await;
1083 let dave_root_id = dave.get("/api/auth/me").await.json()["roots"][0]["id"]
1084 .as_i64()
1085 .unwrap();
1086
1087 // His own root works.
1088 assert_eq!(
1089 dave.get(&format!("/api/files/{dave_root_id}")).await.status,
1090 StatusCode::OK
1091 );
1092 // The admin's root id (1) is not his → 403.
1093 assert_eq!(dave.get("/api/files/1").await.status, StatusCode::FORBIDDEN);
1094 // Writing into a root he doesn't have → 403.
1095 assert_eq!(
1096 dave.raw(
1097 axum::http::Method::POST,
1098 "/api/files/1/evil?action=mkdir",
1099 &[],
1100 Vec::new()
1101 )
1102 .await
1103 .status,
1104 StatusCode::FORBIDDEN
1105 );
1106}
1107
1108/// Listings report a content-sniffed `kind`, not an extension guess.
1109#[tokio::test]
1110async fn listing_reports_sniffed_kinds() {
1111 let env = Env::new().await;
1112 let admin = env.admin().await;
1113 // A PNG named .txt and a text file named .png: the bytes must win.
1114 std::fs::write(
1115 env.file("lies.txt"),
1116 [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
1117 )
1118 .unwrap();
1119 std::fs::write(env.file("lies.png"), "just words\n").unwrap();
1120 std::fs::write(env.file("report.html"), "<!doctype html><p>hi").unwrap();
1121 std::fs::write(env.file("noext"), "plain text, no extension\n").unwrap();
1122
1123 let r = admin.get(&root_path("")).await;
1124 assert_eq!(r.status, StatusCode::OK);
1125 let j = r.json();
1126 let kind = |name: &str| -> String {
1127 j["entries"]
1128 .as_array()
1129 .unwrap()
1130 .iter()
1131 .find(|e| e["name"] == name)
1132 .unwrap_or_else(|| panic!("{name} missing from listing"))["kind"]
1133 .as_str()
1134 .unwrap()
1135 .to_string()
1136 };
1137 assert_eq!(kind("lies.txt"), "image");
1138 assert_eq!(kind("lies.png"), "text");
1139 assert_eq!(kind("report.html"), "text");
1140 assert_eq!(kind("noext"), "text");
1141 assert_eq!(kind("blob.bin"), "binary");
1142 assert_eq!(kind("docs"), "dir");
1143 assert_eq!(kind("config.json"), "text");
1144}
1145
1146/// A file the browser would parse as a document is served sandboxed, so it
1147/// can render as a page without being able to act as the app. Scriptable
1148/// files are never frameable; non-scriptable previews are frameable by the
1149/// app itself only.
1150#[tokio::test]
1151async fn scriptable_files_are_served_sandboxed() {
1152 let env = Env::new().await;
1153 let admin = env.admin().await;
1154 std::fs::write(env.file("page.html"), "<!doctype html><p>hi").unwrap();
1155 std::fs::write(
1156 env.file("logo.svg"),
1157 "<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
1158 )
1159 .unwrap();
1160
1161 for name in ["page.html", "logo.svg"] {
1162 let r = admin
1163 .get(&format!("{}?action=preview", root_path(name)))
1164 .await;
1165 assert_eq!(r.status, StatusCode::OK);
1166 let csp = r.header("content-security-policy").unwrap();
1167 assert!(csp.contains("sandbox "), "{name} not sandboxed: {csp}");
1168 assert!(csp.contains("allow-scripts"), "{name}: {csp}");
1169 // The whole security property: an opaque origin.
1170 assert!(
1171 !csp.contains("allow-same-origin"),
1172 "{name} must never get allow-same-origin: {csp}"
1173 );
1174 assert!(
1175 !csp.contains("allow-top-navigation ") && !csp.contains("allow-popups-to-escape"),
1176 "{name}: {csp}"
1177 );
1178 // Still rendered as a document, not downloaded.
1179 assert!(
1180 r.header("content-disposition")
1181 .unwrap()
1182 .starts_with("inline")
1183 );
1184 // Never frameable: same-origin framing would give its JS access to
1185 // the app.
1186 assert!(
1187 csp.contains("frame-ancestors 'none'"),
1188 "{name} must never be frameable: {csp}"
1189 );
1190 assert_eq!(
1191 r.header("x-frame-options").as_deref(),
1192 Some("DENY"),
1193 "{name}"
1194 );
1195 }
1196
1197 // A non-scriptable preview is frameable by the app itself only.
1198 let r = admin
1199 .get(&format!("{}?action=preview", root_path("blob.bin")))
1200 .await;
1201 let csp = r.header("content-security-policy").unwrap();
1202 assert!(!csp.contains("sandbox"), "{csp}");
1203 assert!(
1204 csp.contains("frame-ancestors 'self'"),
1205 "preview must be frameable same-origin: {csp}"
1206 );
1207 assert_eq!(r.header("x-frame-options").as_deref(), Some("SAMEORIGIN"));
1208
1209 // The same file as a *download* keeps the app policy (unframeable).
1210 let r = admin
1211 .get(&format!("{}?action=download", root_path("blob.bin")))
1212 .await;
1213 let csp = r.header("content-security-policy").unwrap();
1214 assert!(
1215 csp.contains("frame-ancestors 'none'"),
1216 "download must keep the app policy: {csp}"
1217 );
1218 assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY"));
1219
1220 // And the app's own pages are untouched by the `if_not_present` switch.
1221 let r = admin.get("/").await;
1222 let csp = r.header("content-security-policy").unwrap();
1223 assert!(
1224 csp.contains("wasm-unsafe-eval") && !csp.contains("sandbox"),
1225 "{csp}"
1226 );
1227 assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY"));
1228}
1229
1230#[tokio::test]
1231async fn archive_does_not_follow_symlinks_out_of_the_root() {
1232 let env = Env::new().await;
1233 let admin = env.admin().await;
1234
1235 // A directory outside the served root, linked to from inside it.
1236 let outside = tempfile::tempdir().unwrap();
1237 std::fs::write(outside.path().join("secret.txt"), "leaked").unwrap();
1238 std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap();
1239
1240 let r = admin
1241 .get(&format!("{}?action=download&format=tar", root_path("docs")))
1242 .await;
1243 assert_eq!(r.status, StatusCode::OK);
1244 let map = tar_map(&r.body, Compress::None);
1245 assert!(
1246 !map.keys().any(|k| k.contains("secret.txt")),
1247 "archive escaped the root: {:?}",
1248 map.keys().collect::<Vec<_>>()
1249 );
1250 // The legitimate entries are still there.
1251 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
1252 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
1253}
1254
1255#[tokio::test]
1256async fn listing_is_paged_in_the_requested_order() {
1257 let env = Env::new().await;
1258 let admin = env.admin().await;
1259
1260 let dir = env.file("paged");
1261 std::fs::create_dir_all(dir.join("sub")).unwrap();
1262 for i in 0..25 {
1263 std::fs::write(dir.join(format!("f{i:02}")), vec![b'x'; i]).unwrap();
1264 }
1265 let names = |j: &serde_json::Value| -> Vec<String> {
1266 j["entries"]
1267 .as_array()
1268 .unwrap()
1269 .iter()
1270 .map(|e| e["name"].as_str().unwrap().to_string())
1271 .collect()
1272 };
1273
1274 // No params: the whole folder by name, folders first.
1275 let j = admin.get(&root_path("paged")).await.json();
1276 assert_eq!(
1277 (j["total"].as_u64(), j["offset"].as_u64()),
1278 (Some(26), Some(0))
1279 );
1280 assert_eq!(names(&j).len(), 26);
1281 assert_eq!(names(&j)[0], "sub");
1282
1283 let r = admin
1284 .get(&format!(
1285 "{}?sort=size&desc=true&offset=10&limit=10",
1286 root_path("paged")
1287 ))
1288 .await;
1289 assert_eq!(r.status, StatusCode::OK);
1290 let j = r.json();
1291 assert_eq!(
1292 (j["total"].as_u64(), j["offset"].as_u64()),
1293 (Some(26), Some(10))
1294 );
1295 // Index 0 is "sub", then f24 down to f00.
1296 let want: Vec<String> = (6..=15).rev().map(|i| format!("f{i:02}")).collect();
1297 assert_eq!(names(&j), want);
1298
1299 // An offset past the end returns the last page.
1300 let j = admin
1301 .get(&format!("{}?offset=999&limit=10", root_path("paged")))
1302 .await
1303 .json();
1304 assert_eq!(j["offset"].as_u64(), Some(20));
1305 assert_eq!(names(&j).len(), 6);
1306
1307 let j = admin
1308 .get(&format!("{}?dirs=true", root_path("paged")))
1309 .await
1310 .json();
1311 assert_eq!(names(&j), ["sub"]);
1312 assert_eq!(j["total"].as_u64(), Some(1));
1313}
1314
1315#[tokio::test]
1316async fn download_revalidates_with_last_modified() {
1317 let env = Env::new().await;
1318 let admin = env.admin().await;
1319 let path = format!("{}?action=download", root_path("editme.txt"));
1320 let file = env.root.path().join("editme.txt");
1321
1322 // A file written in the last two seconds gets no validator. Pin the mtime
1323 // to "now" first: the fixture is written during `Env` setup, which under a
1324 // loaded parallel run can take longer than that two-second window.
1325 std::fs::File::options()
1326 .write(true)
1327 .open(&file)
1328 .unwrap()
1329 .set_modified(std::time::SystemTime::now())
1330 .unwrap();
1331 let r = admin.get(&path).await;
1332 assert_eq!(r.status, StatusCode::OK);
1333 assert!(r.header("last-modified").is_none());
1334 // Nor a 304, whatever date the client sends: a second write in the same
1335 // second would go unseen.
1336 let r = admin
1337 .raw(
1338 axum::http::Method::GET,
1339 &path,
1340 &[("if-modified-since", "Fri, 01 Jan 2100 00:00:00 GMT")],
1341 Vec::new(),
1342 )
1343 .await;
1344 assert_eq!(r.status, StatusCode::OK);
1345 assert_eq!(r.body, b"v1");
1346 // No validator here, so the policy matters more: with no Cache-Control a
1347 // shared cache may apply heuristic freshness.
1348 assert_eq!(
1349 r.header("cache-control").as_deref(),
1350 Some("private, no-cache"),
1351 "a file response always carries a caching policy"
1352 );
1353
1354 // Backdate the file so the validator appears.
1355 let f = std::fs::File::options().write(true).open(&file).unwrap();
1356 f.set_modified(
1357 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
1358 )
1359 .unwrap();
1360 let r = admin.get(&path).await;
1361 assert_eq!(r.status, StatusCode::OK);
1362 assert_eq!(
1363 r.header("cache-control").as_deref(),
1364 Some("private, no-cache")
1365 );
1366 let lm = r.header("last-modified").expect("Last-Modified header");
1367
1368 let r = admin
1369 .raw(
1370 axum::http::Method::GET,
1371 &path,
1372 &[("if-modified-since", lm.as_str())],
1373 Vec::new(),
1374 )
1375 .await;
1376 assert_eq!(r.status, StatusCode::NOT_MODIFIED);
1377 assert!(r.body.is_empty());
1378 // The refresh repeats the policy, so the stored entry does not lose it.
1379 assert_eq!(
1380 r.header("cache-control").as_deref(),
1381 Some("private, no-cache")
1382 );
1383}
1384
1385/// An mtime before 1970 has no HTTP date. The file is still served, whole
1386/// and without a validator.
1387#[tokio::test]
1388async fn file_dated_before_1970_is_served() {
1389 let env = Env::new().await;
1390 let admin = env.admin().await;
1391 std::fs::File::options()
1392 .write(true)
1393 .open(env.file("editme.txt"))
1394 .unwrap()
1395 .set_modified(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400))
1396 .unwrap();
1397 for action in ["download", "preview"] {
1398 let r = admin
1399 .raw(
1400 axum::http::Method::GET,
1401 &format!("{}?action={action}", root_path("editme.txt")),
1402 &[("range", "bytes=0-0")],
1403 Vec::new(),
1404 )
1405 .await;
1406 assert_eq!(r.status, StatusCode::OK, "{action}");
1407 assert_eq!(r.body, b"v1", "{action}");
1408 assert!(r.header("last-modified").is_none(), "{action}");
1409 assert_eq!(
1410 r.header("cache-control").as_deref(),
1411 Some("private, no-cache")
1412 );
1413 }
1414}
1415
1416/// The browser copies a 304's CSP onto the cached response, so a revalidated
1417/// file must keep the policy its 200 had, not get the app's.
1418#[tokio::test]
1419async fn revalidation_keeps_the_file_policy() {
1420 let env = Env::new().await;
1421 let admin = env.admin().await;
1422 std::fs::write(env.file("page.html"), "<!doctype html><p>hi").unwrap();
1423 for name in ["page.html", "blob.bin"] {
1424 std::fs::File::options()
1425 .write(true)
1426 .open(env.file(name))
1427 .unwrap()
1428 .set_modified(
1429 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
1430 )
1431 .unwrap();
1432 let path = format!("{}?action=preview", root_path(name));
1433 let first = admin.get(&path).await;
1434 let lm = first.header("last-modified").expect("Last-Modified header");
1435 let r = admin
1436 .raw(
1437 axum::http::Method::GET,
1438 &path,
1439 &[("if-modified-since", lm.as_str())],
1440 Vec::new(),
1441 )
1442 .await;
1443 assert_eq!(r.status, StatusCode::NOT_MODIFIED, "{name}");
1444 assert_eq!(
1445 r.header("content-security-policy"),
1446 first.header("content-security-policy"),
1447 "{name}"
1448 );
1449 assert_eq!(
1450 r.header("x-frame-options"),
1451 first.header("x-frame-options"),
1452 "{name}"
1453 );
1454 }
1455}
1456
1457// ---------------------------------------------------------------------------
1458// Symlinks: an operation on a name acts on the entry, not on what it points at
1459// ---------------------------------------------------------------------------
1460
1461/// Create `link` inside the root, pointing at `target`.
1462fn symlink(env: &Env, target: &std::path::Path, link: &str) {
1463 std::os::unix::fs::symlink(target, env.file(link)).unwrap();
1464}
1465
1466#[tokio::test]
1467async fn deleting_a_symlink_removes_the_link_not_its_target() {
1468 let env = Env::new().await;
1469 let admin = env.admin().await;
1470 symlink(&env, &env.file("notes.md"), "alias.md");
1471
1472 let r = admin.delete("/api/files/1/alias.md").await;
1473 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1474
1475 assert!(env.file("alias.md").symlink_metadata().is_err());
1476 assert_eq!(
1477 std::fs::read_to_string(env.file("notes.md")).unwrap(),
1478 "# notes",
1479 "the delete followed the link"
1480 );
1481}
1482
1483#[tokio::test]
1484async fn a_dangling_symlink_can_be_deleted() {
1485 let env = Env::new().await;
1486 let admin = env.admin().await;
1487 symlink(&env, &env.file("gone.txt"), "dangling.md");
1488
1489 // Resolving strictly reports "not found", which would leave the link
1490 // undeletable through the API.
1491 let r = admin.delete("/api/files/1/dangling.md").await;
1492 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1493 assert!(env.file("dangling.md").symlink_metadata().is_err());
1494}
1495
1496#[tokio::test]
1497async fn renaming_a_symlink_renames_the_link() {
1498 let env = Env::new().await;
1499 let admin = env.admin().await;
1500 symlink(&env, &env.file("docs/a.txt"), "alias.txt");
1501
1502 let r = admin
1503 .post_json(
1504 "/api/files/1/alias.txt",
1505 &json!({ "op": "rename", "new_name": "renamed.txt" }),
1506 )
1507 .await;
1508 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1509
1510 // The link moved. Following it would have renamed the target, and into
1511 // the target's own directory at that.
1512 assert!(
1513 env.file("renamed.txt")
1514 .symlink_metadata()
1515 .unwrap()
1516 .file_type()
1517 .is_symlink()
1518 );
1519 assert!(env.file("docs/a.txt").exists());
1520 assert!(!env.file("docs/renamed.txt").exists());
1521}
1522
1523#[tokio::test]
1524async fn moving_a_symlink_moves_the_link() {
1525 let env = Env::new().await;
1526 let admin = env.admin().await;
1527 symlink(&env, &env.file("notes.md"), "alias.md");
1528
1529 let r = admin
1530 .post_json(
1531 "/api/files/1/alias.md",
1532 &json!({ "op": "move", "dst_root_id": 1, "dst": "docs" }),
1533 )
1534 .await;
1535 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1536
1537 assert!(
1538 env.file("docs/alias.md")
1539 .symlink_metadata()
1540 .unwrap()
1541 .file_type()
1542 .is_symlink()
1543 );
1544 assert!(env.file("notes.md").exists(), "the move followed the link");
1545}
1546
1547#[tokio::test]
1548async fn copying_onto_a_symlink_replaces_it() {
1549 let env = Env::new().await;
1550 let admin = env.admin().await;
1551
1552 // A link inside the root aimed outside it. `std::fs::copy` follows a
1553 // destination symlink, so without unlinking it first the write lands
1554 // outside the root with every path check passing.
1555 let outside = env.root.path().parent().unwrap().join("outside.txt");
1556 std::fs::write(&outside, "SECRET").unwrap();
1557 std::fs::create_dir_all(env.file("dest")).unwrap();
1558 std::os::unix::fs::symlink(&outside, env.file("dest/notes.md")).unwrap();
1559
1560 let r = admin
1561 .post_json(
1562 "/api/files/1/notes.md",
1563 &json!({ "op": "copy", "dst_root_id": 1, "dst": "dest", "overwrite": true }),
1564 )
1565 .await;
1566 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1567
1568 assert_eq!(
1569 std::fs::read_to_string(&outside).unwrap(),
1570 "SECRET",
1571 "the copy escaped the root"
1572 );
1573 assert_eq!(
1574 std::fs::read_to_string(env.file("dest/notes.md")).unwrap(),
1575 "# notes"
1576 );
1577 assert!(
1578 !env.file("dest/notes.md")
1579 .symlink_metadata()
1580 .unwrap()
1581 .file_type()
1582 .is_symlink()
1583 );
1584}
1585
1586#[tokio::test]
1587async fn copying_a_symlink_copies_what_it_points_at() {
1588 let env = Env::new().await;
1589 let admin = env.admin().await;
1590 symlink(&env, &env.file("notes.md"), "alias.md");
1591 std::fs::create_dir_all(env.file("dest")).unwrap();
1592
1593 // The source is followed on purpose: a copy wants the bytes, like `cp`.
1594 let r = admin
1595 .post_json(
1596 "/api/files/1/alias.md",
1597 &json!({ "op": "copy", "dst_root_id": 1, "dst": "dest" }),
1598 )
1599 .await;
1600 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1601 assert_eq!(
1602 std::fs::read_to_string(env.file("dest/alias.md")).unwrap(),
1603 "# notes"
1604 );
1605}
1606
1607#[tokio::test]
1608async fn a_symlink_out_of_the_root_still_cannot_be_read_or_written() {
1609 let env = Env::new().await;
1610 let admin = env.admin().await;
1611 let outside = env.root.path().parent().unwrap().join("outside.txt");
1612 std::fs::write(&outside, "SECRET").unwrap();
1613 std::os::unix::fs::symlink(&outside, env.file("escape.txt")).unwrap();
1614
1615 // Reads and content writes do follow a link, so containment rests on
1616 // `ensure_within` rejecting one that leaves the root.
1617 let r = admin.get("/api/files/1/escape.txt?action=content").await;
1618 assert!(r.status.is_client_error(), "{}", r.status);
1619 assert_ne!(r.text(), "SECRET");
1620
1621 let r = admin
1622 .put_content("/api/files/1/escape.txt?action=content", b"payload", None)
1623 .await;
1624 assert!(r.status.is_client_error(), "{}", r.status);
1625 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "SECRET");
1626
1627 // Deleting the link is fine: that touches only the entry inside the root.
1628 let r = admin.delete("/api/files/1/escape.txt").await;
1629 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1630 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "SECRET");
1631}
1632