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