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