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