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.
709 let r = admin
710 .post_multipart(&root_path(""), &[("new", b"dir?")], "")
711 .await;
712 assert_eq!(r.status, StatusCode::CONFLICT);
713
714 // Path traversal in a part name → 400.
715 let r = admin
716 .post_multipart(&root_path(""), &[("../evil.txt", b"x")], "")
717 .await;
718 assert!(matches!(
719 r.status,
720 StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN
721 ));
722 assert!(!env.file("../evil.txt").exists());
723 assert!(!env.root.path().parent().unwrap().join("evil.txt").exists());
724
725 // No parts at all → 400.
726 let (ct, body) = multipart_body(&[], "b");
727 let r = admin
728 .raw(
729 axum::http::Method::POST,
730 &root_path(""),
731 &[("content-type", &ct)],
732 body,
733 )
734 .await;
735 assert_eq!(r.status, StatusCode::BAD_REQUEST);
736}
737
738#[cfg(unix)]
739#[tokio::test]
740async fn upload_does_not_follow_symlinked_directories_out_of_the_root() {
741 let env = Env::new().await;
742 let admin = env.admin().await;
743 let outside = tempfile::tempdir().unwrap();
744 std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap();
745
746 // Into the linked directory itself, and into a new folder below it.
747 for part in ["link/escaped.txt", "link/deeper/escaped.txt"] {
748 let r = admin
749 .post_multipart(&root_path("docs"), &[(part, b"leak")], "")
750 .await;
751 assert_eq!(r.status, StatusCode::FORBIDDEN, "{part}: {}", r.text());
752 }
753 assert!(!outside.path().join("escaped.txt").exists());
754 assert!(!outside.path().join("deeper").exists());
755 assert!(
756 std::fs::read_dir(outside.path()).unwrap().next().is_none(),
757 "no temp file may be left outside the root"
758 );
759
760 // A symlink that stays inside the root still works.
761 std::os::unix::fs::symlink(env.file("src"), env.file("docs/inside")).unwrap();
762 let r = admin
763 .post_multipart(&root_path("docs"), &[("inside/ok.txt", b"fine")], "")
764 .await;
765 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
766 assert_eq!(std::fs::read(env.file("src/ok.txt")).unwrap(), b"fine");
767}
768
769/// A multipart body that stops inside a part: the headers and part of the
770/// payload, then end of stream with no closing boundary. That is what reaches
771/// the server when the user closes the tab or the connection drops.
772async fn upload_stopped_mid_part(env: &Env, admin: &Client) -> StatusCode {
773 let mut body: Vec<u8> = Vec::new();
774 body.extend_from_slice(
775 b"--B\r\nContent-Disposition: form-data; name=\"interrupted.txt\"\r\n\r\n",
776 );
777 body.extend_from_slice(&vec![b'x'; 300 * 1024]);
778 let stream = futures_util::stream::unfold(body, |mut rest| async move {
779 if rest.len() > 1024 {
780 let n = rest.len() / 2;
781 let chunk: Vec<u8> = rest.drain(..n).collect();
782 Some((
783 Ok::<_, std::io::Error>(axum::body::Bytes::from(chunk)),
784 rest,
785 ))
786 } else {
787 None // EOF: the terminating boundary never arrives
788 }
789 });
790 let req = axum::http::Request::builder()
791 .method(axum::http::Method::POST)
792 .uri(root_path(""))
793 .header("content-type", "multipart/form-data; boundary=B")
794 .header(
795 "cookie",
796 format!("fbng_session={}", admin.cookie.as_ref().unwrap()),
797 )
798 .body(axum::body::Body::from_stream(stream))
799 .unwrap();
800 let res = tower::ServiceExt::oneshot(env.app.clone(), req)
801 .await
802 .expect("request");
803 let status = res.status();
804 let _ = http_body_util::BodyExt::collect(res.into_body()).await;
805 status
806}
807
808/// Every entry name in the server root, hidden ones included.
809fn entries(env: &Env) -> Vec<String> {
810 std::fs::read_dir(env.root.path())
811 .unwrap()
812 .flatten()
813 .map(|e| e.file_name().to_string_lossy().into_owned())
814 .collect()
815}
816
817/// An upload is streamed to `.upload-<token>` and renamed into place. A part
818/// that never reaches the rename must take the scratch file with it. Nothing
819/// ever names that file again, and listings show it.
820#[tokio::test]
821async fn an_interrupted_upload_leaves_no_scratch_file() {
822 let env = Env::new().await;
823 let admin = env.admin().await;
824
825 let status = upload_stopped_mid_part(&env, &admin).await;
826 assert!(
827 status.is_client_error(),
828 "expected a rejection, got {status}"
829 );
830 assert!(
831 !entries(&env).iter().any(|n| n.starts_with(".upload-")),
832 "scratch file left behind: {:?}",
833 entries(&env)
834 );
835 assert!(!env.file("interrupted.txt").exists());
836
837 // The same assertion after a completed upload, so a guard that never
838 // disarms cannot pass this test by accident.
839 let r = admin
840 .post_multipart(&root_path(""), &[("finished.txt", b"whole")], "")
841 .await;
842 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
843 assert_eq!(std::fs::read(env.file("finished.txt")).unwrap(), b"whole");
844 assert!(
845 !entries(&env).iter().any(|n| n.starts_with(".upload-")),
846 "scratch file left after a completed upload: {:?}",
847 entries(&env)
848 );
849}
850
851#[tokio::test]
852async fn read_only_root_blocks_writes_but_allows_reads() {
853 let env = Env::new().await;
854 let admin = env.admin().await;
855 create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await;
856 let carol = login(&env, "carol", "carolpass1").await;
857 let carol_root_id = carol.get("/api/auth/me").await.json()["roots"][0]["id"]
858 .as_i64()
859 .unwrap();
860
861 // Reads work.
862 let r = carol.get(&format!("/api/files/{carol_root_id}")).await;
863 assert_eq!(r.status, StatusCode::OK);
864 assert!(!r.json()["entries"].as_array().unwrap().is_empty());
865 let r = carol
866 .get(&format!("/api/files/{carol_root_id}/a.txt?action=download"))
867 .await;
868 assert_eq!(r.body, b"file a");
869
870 // Writes are blocked.
871 let base = format!("/api/files/{carol_root_id}/x?action=mkdir");
872 assert_eq!(
873 carol
874 .raw(axum::http::Method::POST, &base, &[], Vec::new())
875 .await
876 .status,
877 StatusCode::FORBIDDEN
878 );
879 assert_eq!(
880 carol
881 .delete(&format!("/api/files/{carol_root_id}/a.txt"))
882 .await
883 .status,
884 StatusCode::FORBIDDEN
885 );
886 assert_eq!(
887 carol
888 .post_json(
889 &format!("/api/files/{carol_root_id}/a.txt"),
890 &json!({ "op": "rename", "new_name": "b.txt" })
891 )
892 .await
893 .status,
894 StatusCode::FORBIDDEN
895 );
896}
897
898#[tokio::test]
899async fn user_cannot_touch_foreign_root() {
900 let env = Env::new().await;
901 let admin = env.admin().await;
902 create_user(&admin, "dave", "davepass12", &[("src", "rw")]).await;
903 let dave = login(&env, "dave", "davepass12").await;
904 let dave_root_id = dave.get("/api/auth/me").await.json()["roots"][0]["id"]
905 .as_i64()
906 .unwrap();
907
908 // His own root works.
909 assert_eq!(
910 dave.get(&format!("/api/files/{dave_root_id}")).await.status,
911 StatusCode::OK
912 );
913 // The admin's root id (1) is not his → 403.
914 assert_eq!(dave.get("/api/files/1").await.status, StatusCode::FORBIDDEN);
915 // Writing into a root he doesn't have → 403.
916 assert_eq!(
917 dave.raw(
918 axum::http::Method::POST,
919 "/api/files/1/evil?action=mkdir",
920 &[],
921 Vec::new()
922 )
923 .await
924 .status,
925 StatusCode::FORBIDDEN
926 );
927}
928
929/// Listings report a content-sniffed `kind`, not an extension guess.
930#[tokio::test]
931async fn listing_reports_sniffed_kinds() {
932 let env = Env::new().await;
933 let admin = env.admin().await;
934 // A PNG named .txt and a text file named .png: the bytes must win.
935 std::fs::write(
936 env.file("lies.txt"),
937 [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
938 )
939 .unwrap();
940 std::fs::write(env.file("lies.png"), "just words\n").unwrap();
941 std::fs::write(env.file("report.html"), "<!doctype html><p>hi").unwrap();
942 std::fs::write(env.file("noext"), "plain text, no extension\n").unwrap();
943
944 let r = admin.get(&root_path("")).await;
945 assert_eq!(r.status, StatusCode::OK);
946 let j = r.json();
947 let kind = |name: &str| -> String {
948 j["entries"]
949 .as_array()
950 .unwrap()
951 .iter()
952 .find(|e| e["name"] == name)
953 .unwrap_or_else(|| panic!("{name} missing from listing"))["kind"]
954 .as_str()
955 .unwrap()
956 .to_string()
957 };
958 assert_eq!(kind("lies.txt"), "image");
959 assert_eq!(kind("lies.png"), "text");
960 assert_eq!(kind("report.html"), "text");
961 assert_eq!(kind("noext"), "text");
962 assert_eq!(kind("blob.bin"), "binary");
963 assert_eq!(kind("docs"), "dir");
964 assert_eq!(kind("config.json"), "text");
965}
966
967/// A file the browser would parse as a document is served sandboxed, so it
968/// can render as a page without being able to act as the app. Scriptable
969/// files are never frameable; non-scriptable previews are frameable by the
970/// app itself only.
971#[tokio::test]
972async fn scriptable_files_are_served_sandboxed() {
973 let env = Env::new().await;
974 let admin = env.admin().await;
975 std::fs::write(env.file("page.html"), "<!doctype html><p>hi").unwrap();
976 std::fs::write(
977 env.file("logo.svg"),
978 "<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
979 )
980 .unwrap();
981
982 for name in ["page.html", "logo.svg"] {
983 let r = admin
984 .get(&format!("{}?action=preview", root_path(name)))
985 .await;
986 assert_eq!(r.status, StatusCode::OK);
987 let csp = r.header("content-security-policy").unwrap();
988 assert!(csp.contains("sandbox "), "{name} not sandboxed: {csp}");
989 assert!(csp.contains("allow-scripts"), "{name}: {csp}");
990 // The whole security property: an opaque origin.
991 assert!(
992 !csp.contains("allow-same-origin"),
993 "{name} must never get allow-same-origin: {csp}"
994 );
995 assert!(
996 !csp.contains("allow-top-navigation ") && !csp.contains("allow-popups-to-escape"),
997 "{name}: {csp}"
998 );
999 // Still rendered as a document, not downloaded.
1000 assert!(
1001 r.header("content-disposition")
1002 .unwrap()
1003 .starts_with("inline")
1004 );
1005 // Never frameable: same-origin framing would give its JS access to
1006 // the app.
1007 assert!(
1008 csp.contains("frame-ancestors 'none'"),
1009 "{name} must never be frameable: {csp}"
1010 );
1011 assert_eq!(
1012 r.header("x-frame-options").as_deref(),
1013 Some("DENY"),
1014 "{name}"
1015 );
1016 }
1017
1018 // A non-scriptable preview is frameable by the app itself only.
1019 let r = admin
1020 .get(&format!("{}?action=preview", root_path("blob.bin")))
1021 .await;
1022 let csp = r.header("content-security-policy").unwrap();
1023 assert!(!csp.contains("sandbox"), "{csp}");
1024 assert!(
1025 csp.contains("frame-ancestors 'self'"),
1026 "preview must be frameable same-origin: {csp}"
1027 );
1028 assert_eq!(r.header("x-frame-options").as_deref(), Some("SAMEORIGIN"));
1029
1030 // The same file as a *download* keeps the app policy (unframeable).
1031 let r = admin
1032 .get(&format!("{}?action=download", root_path("blob.bin")))
1033 .await;
1034 let csp = r.header("content-security-policy").unwrap();
1035 assert!(
1036 csp.contains("frame-ancestors 'none'"),
1037 "download must keep the app policy: {csp}"
1038 );
1039 assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY"));
1040
1041 // And the app's own pages are untouched by the `if_not_present` switch.
1042 let r = admin.get("/").await;
1043 let csp = r.header("content-security-policy").unwrap();
1044 assert!(
1045 csp.contains("wasm-unsafe-eval") && !csp.contains("sandbox"),
1046 "{csp}"
1047 );
1048 assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY"));
1049}
1050
1051#[tokio::test]
1052async fn archive_does_not_follow_symlinks_out_of_the_root() {
1053 let env = Env::new().await;
1054 let admin = env.admin().await;
1055
1056 // A directory outside the served root, linked to from inside it.
1057 let outside = tempfile::tempdir().unwrap();
1058 std::fs::write(outside.path().join("secret.txt"), "leaked").unwrap();
1059 std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap();
1060
1061 let r = admin
1062 .get(&format!("{}?action=download&format=tar", root_path("docs")))
1063 .await;
1064 assert_eq!(r.status, StatusCode::OK);
1065 let map = tar_map(&r.body, Compress::None);
1066 assert!(
1067 !map.keys().any(|k| k.contains("secret.txt")),
1068 "archive escaped the root: {:?}",
1069 map.keys().collect::<Vec<_>>()
1070 );
1071 // The legitimate entries are still there.
1072 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
1073 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
1074}
1075
1076#[tokio::test]
1077async fn listing_is_capped_and_reports_truncation() {
1078 let env = Env::new().await;
1079 let admin = env.admin().await;
1080
1081 // A normal listing is not truncated.
1082 let r = admin.get(&root_path("")).await;
1083 assert_eq!(r.json()["truncated"], false);
1084
1085 let big = env.file("big");
1086 std::fs::create_dir_all(&big).unwrap();
1087 for i in 0..api_types::MAX_LIST_ENTRIES + 5 {
1088 std::fs::write(big.join(format!("f{i:06}")), b"").unwrap();
1089 }
1090 let r = admin.get(&root_path("big")).await;
1091 assert_eq!(r.status, StatusCode::OK);
1092 let j = r.json();
1093 assert_eq!(
1094 j["entries"].as_array().unwrap().len(),
1095 api_types::MAX_LIST_ENTRIES
1096 );
1097 assert_eq!(j["truncated"], true);
1098}
1099
1100#[tokio::test]
1101async fn download_revalidates_with_last_modified() {
1102 let env = Env::new().await;
1103 let admin = env.admin().await;
1104 let path = format!("{}?action=download", root_path("editme.txt"));
1105 let file = env.root.path().join("editme.txt");
1106
1107 // A file written in the last two seconds gets no validator. Pin the mtime
1108 // to "now" first: the fixture is written during `Env` setup, which under a
1109 // loaded parallel run can take longer than that two-second window.
1110 std::fs::File::options()
1111 .write(true)
1112 .open(&file)
1113 .unwrap()
1114 .set_modified(std::time::SystemTime::now())
1115 .unwrap();
1116 let r = admin.get(&path).await;
1117 assert_eq!(r.status, StatusCode::OK);
1118 assert!(r.header("last-modified").is_none());
1119 // No validator here, so the policy matters more: with no Cache-Control a
1120 // shared cache may apply heuristic freshness.
1121 assert_eq!(
1122 r.header("cache-control").as_deref(),
1123 Some("private, no-cache"),
1124 "a file response always carries a caching policy"
1125 );
1126
1127 // Backdate the file so the validator appears.
1128 let f = std::fs::File::options().write(true).open(&file).unwrap();
1129 f.set_modified(
1130 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
1131 )
1132 .unwrap();
1133 let r = admin.get(&path).await;
1134 assert_eq!(r.status, StatusCode::OK);
1135 assert_eq!(
1136 r.header("cache-control").as_deref(),
1137 Some("private, no-cache")
1138 );
1139 let lm = r.header("last-modified").expect("Last-Modified header");
1140
1141 let r = admin
1142 .raw(
1143 axum::http::Method::GET,
1144 &path,
1145 &[("if-modified-since", lm.as_str())],
1146 Vec::new(),
1147 )
1148 .await;
1149 assert_eq!(r.status, StatusCode::NOT_MODIFIED);
1150 assert!(r.body.is_empty());
1151 // The refresh repeats the policy, so the stored entry does not lose it.
1152 assert_eq!(
1153 r.header("cache-control").as_deref(),
1154 Some("private, no-cache")
1155 );
1156}
1157
1158// ---------------------------------------------------------------------------
1159// Symlinks: an operation on a name acts on the entry, not on what it points at
1160// ---------------------------------------------------------------------------
1161
1162/// Create `link` inside the root, pointing at `target`.
1163fn symlink(env: &Env, target: &std::path::Path, link: &str) {
1164 std::os::unix::fs::symlink(target, env.file(link)).unwrap();
1165}
1166
1167#[tokio::test]
1168async fn deleting_a_symlink_removes_the_link_not_its_target() {
1169 let env = Env::new().await;
1170 let admin = env.admin().await;
1171 symlink(&env, &env.file("notes.md"), "alias.md");
1172
1173 let r = admin.delete("/api/files/1/alias.md").await;
1174 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1175
1176 assert!(env.file("alias.md").symlink_metadata().is_err());
1177 assert_eq!(
1178 std::fs::read_to_string(env.file("notes.md")).unwrap(),
1179 "# notes",
1180 "the delete followed the link"
1181 );
1182}
1183
1184#[tokio::test]
1185async fn a_dangling_symlink_can_be_deleted() {
1186 let env = Env::new().await;
1187 let admin = env.admin().await;
1188 symlink(&env, &env.file("gone.txt"), "dangling.md");
1189
1190 // Resolving strictly reports "not found", which would leave the link
1191 // undeletable through the API.
1192 let r = admin.delete("/api/files/1/dangling.md").await;
1193 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1194 assert!(env.file("dangling.md").symlink_metadata().is_err());
1195}
1196
1197#[tokio::test]
1198async fn renaming_a_symlink_renames_the_link() {
1199 let env = Env::new().await;
1200 let admin = env.admin().await;
1201 symlink(&env, &env.file("docs/a.txt"), "alias.txt");
1202
1203 let r = admin
1204 .post_json(
1205 "/api/files/1/alias.txt",
1206 &json!({ "op": "rename", "new_name": "renamed.txt" }),
1207 )
1208 .await;
1209 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1210
1211 // The link moved. Following it would have renamed the target, and into
1212 // the target's own directory at that.
1213 assert!(
1214 env.file("renamed.txt")
1215 .symlink_metadata()
1216 .unwrap()
1217 .file_type()
1218 .is_symlink()
1219 );
1220 assert!(env.file("docs/a.txt").exists());
1221 assert!(!env.file("docs/renamed.txt").exists());
1222}
1223
1224#[tokio::test]
1225async fn moving_a_symlink_moves_the_link() {
1226 let env = Env::new().await;
1227 let admin = env.admin().await;
1228 symlink(&env, &env.file("notes.md"), "alias.md");
1229
1230 let r = admin
1231 .post_json(
1232 "/api/files/1/alias.md",
1233 &json!({ "op": "move", "dst_root_id": 1, "dst": "docs" }),
1234 )
1235 .await;
1236 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1237
1238 assert!(
1239 env.file("docs/alias.md")
1240 .symlink_metadata()
1241 .unwrap()
1242 .file_type()
1243 .is_symlink()
1244 );
1245 assert!(env.file("notes.md").exists(), "the move followed the link");
1246}
1247
1248#[tokio::test]
1249async fn copying_onto_a_symlink_replaces_it() {
1250 let env = Env::new().await;
1251 let admin = env.admin().await;
1252
1253 // A link inside the root aimed outside it. `std::fs::copy` follows a
1254 // destination symlink, so without unlinking it first the write lands
1255 // outside the root with every path check passing.
1256 let outside = env.root.path().parent().unwrap().join("outside.txt");
1257 std::fs::write(&outside, "SECRET").unwrap();
1258 std::fs::create_dir_all(env.file("dest")).unwrap();
1259 std::os::unix::fs::symlink(&outside, env.file("dest/notes.md")).unwrap();
1260
1261 let r = admin
1262 .post_json(
1263 "/api/files/1/notes.md",
1264 &json!({ "op": "copy", "dst_root_id": 1, "dst": "dest", "overwrite": true }),
1265 )
1266 .await;
1267 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1268
1269 assert_eq!(
1270 std::fs::read_to_string(&outside).unwrap(),
1271 "SECRET",
1272 "the copy escaped the root"
1273 );
1274 assert_eq!(
1275 std::fs::read_to_string(env.file("dest/notes.md")).unwrap(),
1276 "# notes"
1277 );
1278 assert!(
1279 !env.file("dest/notes.md")
1280 .symlink_metadata()
1281 .unwrap()
1282 .file_type()
1283 .is_symlink()
1284 );
1285}
1286
1287#[tokio::test]
1288async fn copying_a_symlink_copies_what_it_points_at() {
1289 let env = Env::new().await;
1290 let admin = env.admin().await;
1291 symlink(&env, &env.file("notes.md"), "alias.md");
1292 std::fs::create_dir_all(env.file("dest")).unwrap();
1293
1294 // The source is followed on purpose: a copy wants the bytes, like `cp`.
1295 let r = admin
1296 .post_json(
1297 "/api/files/1/alias.md",
1298 &json!({ "op": "copy", "dst_root_id": 1, "dst": "dest" }),
1299 )
1300 .await;
1301 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1302 assert_eq!(
1303 std::fs::read_to_string(env.file("dest/alias.md")).unwrap(),
1304 "# notes"
1305 );
1306}
1307
1308#[tokio::test]
1309async fn a_symlink_out_of_the_root_still_cannot_be_read_or_written() {
1310 let env = Env::new().await;
1311 let admin = env.admin().await;
1312 let outside = env.root.path().parent().unwrap().join("outside.txt");
1313 std::fs::write(&outside, "SECRET").unwrap();
1314 std::os::unix::fs::symlink(&outside, env.file("escape.txt")).unwrap();
1315
1316 // Reads and content writes do follow a link, so containment rests on
1317 // `ensure_within` rejecting one that leaves the root.
1318 let r = admin.get("/api/files/1/escape.txt?action=content").await;
1319 assert!(r.status.is_client_error(), "{}", r.status);
1320 assert_ne!(r.text(), "SECRET");
1321
1322 let r = admin
1323 .put_content("/api/files/1/escape.txt?action=content", b"payload", None)
1324 .await;
1325 assert!(r.status.is_client_error(), "{}", r.status);
1326 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "SECRET");
1327
1328 // Deleting the link is fine: that touches only the entry inside the root.
1329 let r = admin.delete("/api/files/1/escape.txt").await;
1330 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1331 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "SECRET");
1332}
1333