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.iter().map(|e| e["name"].as_str().unwrap()).collect();
32 assert_eq!(
33 names,
34 vec!["docs", "src", "blob.bin", "config.json", "editme.txt", "notes.md"]
35 );
36 // Entry fields.
37 let docs = &entries[0];
38 assert_eq!(docs["is_dir"], true);
39 let editme = entries.iter().find(|e| e["name"] == "editme.txt").unwrap();
40 assert_eq!(editme["is_dir"], false);
41 assert_eq!(editme["size"], 2);
42 assert!(editme["mtime"].as_str().unwrap().ends_with('Z'));
43}
44
45#[tokio::test]
46async fn list_subdir_and_errors() {
47 let env = Env::new().await;
48 let admin = env.admin().await;
49
50 let r = admin.get(&root_path("docs")).await;
51 let j = r.json();
52 let names: Vec<&str> = j
53 .get("entries")
54 .unwrap()
55 .as_array()
56 .unwrap()
57 .iter()
58 .map(|e| e["name"].as_str().unwrap())
59 .collect();
60 assert_eq!(names, vec!["inner", "a.txt"]);
61
62 // Missing path → 404.
63 assert_eq!(
64 admin.get(&root_path("nope")).await.status,
65 StatusCode::NOT_FOUND
66 );
67 // Listing a file → 400.
68 assert_eq!(
69 admin.get(&root_path("editme.txt")).await.status,
70 StatusCode::BAD_REQUEST
71 );
72 // Unknown root id → 403.
73 assert_eq!(
74 admin.get("/api/files/999").await.status,
75 StatusCode::FORBIDDEN
76 );
77 // No session → 401.
78 let anon = Client::new(env.app.clone());
79 assert_eq!(
80 anon.get(&root_path("")).await.status,
81 StatusCode::UNAUTHORIZED
82 );
83}
84
85#[tokio::test]
86async fn path_traversal_is_blocked() {
87 let env = Env::new().await;
88 let admin = env.admin().await;
89
90 // Encoded `..` segments reach the handler and are rejected.
91 let r = admin.get("/api/files/1/%2e%2e%2f%2e%2e%2fetc").await;
92 assert!(
93 r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND,
94 "traversal returned {:?}",
95 r.status
96 );
97 // Literal `..` segments: must never succeed.
98 let r = admin.get("/api/files/1/../../etc").await;
99 assert_ne!(r.status, StatusCode::OK, "literal traversal must not be served");
100 // Traversal inside a deeper path.
101 let r = admin.get("/api/files/1/docs/..%2f..%2fsrc").await;
102 assert!(
103 r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND,
104 "deep traversal returned {:?}",
105 r.status
106 );
107}
108
109#[tokio::test]
110async fn download_single_file() {
111 let env = Env::new().await;
112 let admin = env.admin().await;
113 let r = admin.get(&format!("{}?action=download", root_path("editme.txt"))).await;
114 assert_eq!(r.status, StatusCode::OK);
115 assert_eq!(r.header("content-disposition").as_deref(), Some("attachment; filename=\"editme.txt\""));
116 assert_eq!(r.header("content-type").as_deref(), Some("text/plain"));
117 assert_eq!(r.body, b"v1");
118 // Binary content survives.
119 let r = admin.get(&format!("{}?action=download", root_path("blob.bin"))).await;
120 assert_eq!(r.body, (0..64u8).collect::<Vec<_>>());
121}
122
123#[tokio::test]
124async fn download_folder_as_all_archive_formats() {
125 let env = Env::new().await;
126 let admin = env.admin().await;
127 let path = format!("{}?action=download", root_path("docs"));
128
129 let r = admin.get(&format!("{path}&format=zip")).await;
130 assert_eq!(r.status, StatusCode::OK);
131 assert_eq!(
132 r.header("content-type").as_deref(),
133 Some("application/zip")
134 );
135 assert_eq!(
136 r.header("content-disposition").as_deref(),
137 Some("attachment; filename=\"docs.zip\"")
138 );
139 let map = zip_map(&r.body);
140 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
141 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
142
143 let r = admin.get(&format!("{path}&format=tar")).await;
144 assert_eq!(r.header("content-type").as_deref(), Some("application/x-tar"));
145 assert_eq!(r.header("content-disposition").as_deref(), Some("attachment; filename=\"docs.tar\""));
146 let map = tar_map(&r.body, Compress::None);
147 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
148 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
149
150 let r = admin.get(&format!("{path}&format=tar.gz")).await;
151 assert_eq!(r.header("content-type").as_deref(), Some("application/gzip"));
152 assert_eq!(
153 r.header("content-disposition").as_deref(),
154 Some("attachment; filename=\"docs.tar.gz\"")
155 );
156 let map = tar_map(&r.body, Compress::Gz);
157 assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
158
159 let r = admin.get(&format!("{path}&format=tar.zst")).await;
160 assert_eq!(r.header("content-type").as_deref(), Some("application/zstd"));
161 assert_eq!(
162 r.header("content-disposition").as_deref(),
163 Some("attachment; filename=\"docs.tar.zst\"")
164 );
165 let map = tar_map(&r.body, Compress::Zst);
166 assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
167}
168
169#[tokio::test]
170async fn download_folder_requires_valid_format() {
171 let env = Env::new().await;
172 let admin = env.admin().await;
173 let path = format!("{}?action=download", root_path("docs"));
174 // No format → 400.
175 assert_eq!(admin.get(&path).await.status, StatusCode::BAD_REQUEST);
176 // Unknown format → 400.
177 assert_eq!(
178 admin.get(&format!("{path}&format=rar")).await.status,
179 StatusCode::BAD_REQUEST
180 );
181 // Downloading a file with a format is fine (format ignored).
182 let r = admin
183 .get(&format!("{}?action=download&format=zip", root_path("editme.txt")))
184 .await;
185 assert_eq!(r.status, StatusCode::OK);
186 assert_eq!(r.body, b"v1");
187}
188
189#[tokio::test]
190async fn preview_serves_inline_and_rejects_dirs() {
191 let env = Env::new().await;
192 let admin = env.admin().await;
193 let r = admin.get(&format!("{}?action=preview", root_path("config.json"))).await;
194 assert_eq!(r.status, StatusCode::OK);
195 assert!(r
196 .header("content-disposition")
197 .unwrap()
198 .starts_with("inline;"));
199 assert_eq!(r.body, b"{\"k\": 1}");
200 assert_eq!(
201 admin.get(&format!("{}?action=preview", root_path("docs"))).await.status,
202 StatusCode::BAD_REQUEST
203 );
204}
205
206#[tokio::test]
207async fn content_action_serves_raw_bytes_with_mtime() {
208 let env = Env::new().await;
209 let admin = env.admin().await;
210 let r = admin.get(&format!("{}?action=content", root_path("notes.md"))).await;
211 assert_eq!(r.status, StatusCode::OK);
212 assert_eq!(r.header("content-type").as_deref(), Some("text/plain; charset=utf-8"));
213 let mtime = r.header("x-file-mtime").unwrap();
214 assert!(mtime.parse::<i64>().is_ok());
215 assert_eq!(r.body, b"# notes");
216 assert_eq!(
217 admin.get(&format!("{}?action=content", root_path("docs"))).await.status,
218 StatusCode::BAD_REQUEST
219 );
220}
221
222#[tokio::test]
223async fn content_is_capped_at_two_mibibytes() {
224 let env = Env::new().await;
225 let admin = env.admin().await;
226 let big = vec![b'x'; 2 * 1024 * 1024 + 1];
227 std::fs::write(env.file("big.bin"), &big).unwrap();
228 let r = admin.get(&format!("{}?action=content", root_path("big.bin"))).await;
229 assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
230 // The file itself still downloads fine.
231 let r = admin.get(&format!("{}?action=download", root_path("big.bin"))).await;
232 assert_eq!(r.status, StatusCode::OK);
233 assert_eq!(r.body.len(), big.len());
234}
235
236#[tokio::test]
237async fn editor_save_round_trip_and_conflict() {
238 let env = Env::new().await;
239 let admin = env.admin().await;
240 let path = format!("{}?action=content", root_path("editme.txt"));
241
242 // Read current mtime via the content endpoint.
243 let r = admin.get(&path).await;
244 assert_eq!(r.status, StatusCode::OK);
245 let mtime: i64 = r.header("x-file-mtime").unwrap().parse().unwrap();
246
247 // Save with a matching expected mtime.
248 let r = admin.put_content(&path, b"v2", Some(mtime)).await;
249 assert_eq!(r.status, StatusCode::OK);
250 let new_mtime = r.json()["mtime"].as_i64().unwrap();
251 assert!(new_mtime >= mtime);
252 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2");
253
254 // A stale/wrong expected mtime conflicts (409). Use a value far from the
255 // current mtime so this is deterministic regardless of the filesystem's
256 // timestamp granularity (the mtime may not have advanced after the save).
257 let r = admin.put_content(&path, b"v3", Some(mtime + 999_999)).await;
258 assert_eq!(r.status, StatusCode::CONFLICT);
259 // A conflict must not modify the file.
260 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2");
261
262 // No expected mtime → force save.
263 let r = admin.put_content(&path, b"v4", None).await;
264 assert_eq!(r.status, StatusCode::OK);
265 assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v4");
266
267 // Saving a missing file → 404; a directory → 400.
268 // (PUT without action=content → 400.)
269 let r = admin
270 .raw(
271 axum::http::Method::PUT,
272 &root_path("editme.txt"),
273 &[("content-type", "text/plain")],
274 b"x".to_vec(),
275 )
276 .await;
277 assert_eq!(r.status, StatusCode::BAD_REQUEST);
278
279 let r = admin
280 .put_content(&format!("{}?action=content", root_path("ghost.txt")), b"x", None)
281 .await;
282 assert_eq!(r.status, StatusCode::NOT_FOUND);
283 let r = admin
284 .put_content(&format!("{}?action=content", root_path("docs")), b"x", None)
285 .await;
286 assert_eq!(r.status, StatusCode::BAD_REQUEST);
287
288 // Oversized body → 413.
289 let r = admin
290 .put_content(&path, &vec![b'a'; 2 * 1024 * 1024 + 1], None)
291 .await;
292 assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
293}
294
295#[tokio::test]
296async fn mkdir_and_rename() {
297 let env = Env::new().await;
298 let admin = env.admin().await;
299
300 // mkdir (no content-type → mkdir dispatch).
301 let r = admin.raw(axum::http::Method::POST, &root_path("newdir"), &[], Vec::new()).await;
302 assert_eq!(r.status, StatusCode::OK);
303 assert!(env.file("newdir").is_dir());
304 // Duplicate → 409.
305 let r = admin.raw(axum::http::Method::POST, &root_path("newdir"), &[], Vec::new()).await;
306 assert_eq!(r.status, StatusCode::CONFLICT);
307 // Empty name → 400 (bare root POST with JSON op is rejected too).
308 let r = admin
309 .raw(axum::http::Method::POST, &root_path(""), &[], Vec::new())
310 .await;
311 assert_eq!(r.status, StatusCode::BAD_REQUEST);
312
313 // Rename.
314 let r = admin
315 .post_json(
316 &root_path("editme.txt"),
317 &json!({ "op": "rename", "new_name": "renamed.txt" }),
318 )
319 .await;
320 assert_eq!(r.status, StatusCode::OK);
321 assert!(env.file("renamed.txt").exists());
322 // Conflict.
323 let r = admin
324 .post_json(
325 &root_path("renamed.txt"),
326 &json!({ "op": "rename", "new_name": "config.json" }),
327 )
328 .await;
329 assert_eq!(r.status, StatusCode::CONFLICT);
330 // With overwrite.
331 let r = admin
332 .post_json(
333 &root_path("renamed.txt"),
334 &json!({ "op": "rename", "new_name": "config.json", "overwrite": true }),
335 )
336 .await;
337 assert_eq!(r.status, StatusCode::OK);
338 assert_eq!(std::fs::read(env.file("config.json")).unwrap(), b"v1");
339 // Invalid name.
340 let r = admin
341 .post_json(
342 &root_path("notes.md"),
343 &json!({ "op": "rename", "new_name": "a/b" }),
344 )
345 .await;
346 assert_eq!(r.status, StatusCode::BAD_REQUEST);
347 // Missing source.
348 let r = admin
349 .post_json(&root_path("ghost"), &json!({ "op": "rename", "new_name": "x" }))
350 .await;
351 assert_eq!(r.status, StatusCode::NOT_FOUND);
352 // Unknown op.
353 let r = admin
354 .post_json(&root_path("notes.md"), &json!({ "op": "explode" }))
355 .await;
356 assert_eq!(r.status, StatusCode::BAD_REQUEST);
357}
358
359#[tokio::test]
360async fn move_and_copy_across_dirs() {
361 let env = Env::new().await;
362 let admin = env.admin().await;
363
364 // Move notes.md into docs/.
365 let r = admin
366 .post_json(
367 &root_path("notes.md"),
368 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }),
369 )
370 .await;
371 assert_eq!(r.status, StatusCode::OK);
372 assert!(!env.file("notes.md").exists());
373 assert_eq!(std::fs::read(env.file("docs/notes.md")).unwrap(), b"# notes");
374
375 // Copy docs/inner back out — as a folder.
376 let r = admin
377 .post_json(
378 &root_path("docs/inner"),
379 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
380 )
381 .await;
382 assert_eq!(r.status, StatusCode::OK);
383 assert_eq!(std::fs::read(env.file("src/inner/hello.txt")).unwrap(), b"hello world");
384 assert!(env.file("docs/inner/hello.txt").exists());
385
386 // Conflict without overwrite, ok with: copy into a folder that already
387 // holds a file with the same name.
388 let r = admin
389 .post_json(
390 &root_path("docs/a.txt"),
391 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
392 )
393 .await;
394 assert_eq!(r.status, StatusCode::OK);
395 assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a");
396 std::fs::write(env.file("docs/a.txt"), "file a2").unwrap();
397 let r = admin
398 .post_json(
399 &root_path("docs/a.txt"),
400 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
401 )
402 .await;
403 assert_eq!(r.status, StatusCode::CONFLICT);
404 let r = admin
405 .post_json(
406 &root_path("docs/a.txt"),
407 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src", "overwrite": true }),
408 )
409 .await;
410 assert_eq!(r.status, StatusCode::OK);
411 assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a2");
412
413 // Copying an item into its own folder (same path) is a no-op success.
414 let r = admin
415 .post_json(
416 &root_path("docs/a.txt"),
417 &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "docs" }),
418 )
419 .await;
420 assert_eq!(r.status, StatusCode::OK);
421
422 // Moving a folder into itself → 400.
423 let r = admin
424 .post_json(
425 &root_path("docs"),
426 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }),
427 )
428 .await;
429 assert_eq!(r.status, StatusCode::BAD_REQUEST);
430
431 // Missing dst_root_id / dst dir.
432 let r = admin
433 .post_json(&root_path("docs/a.txt"), &json!({ "op": "move" }))
434 .await;
435 assert_eq!(r.status, StatusCode::BAD_REQUEST);
436 let r = admin
437 .post_json(
438 &root_path("docs/a.txt"),
439 &json!({ "op": "move", "dst_root_id": ROOT, "dst": "no-such-dir" }),
440 )
441 .await;
442 assert_eq!(r.status, StatusCode::NOT_FOUND);
443}
444
445#[tokio::test]
446async fn delete_file_and_folder() {
447 let env = Env::new().await;
448 let admin = env.admin().await;
449
450 let r = admin.delete(&root_path("editme.txt")).await;
451 assert_eq!(r.status, StatusCode::OK);
452 assert_eq!(r.json()["is_dir"], false);
453 assert!(!env.file("editme.txt").exists());
454
455 let r = admin.delete(&root_path("docs")).await;
456 assert_eq!(r.json()["is_dir"], true);
457 assert!(!env.file("docs").exists());
458
459 // Missing → 404. A DELETE on the bare root matches no route's method →
460 // 405 (the path only has GET/POST routes).
461 assert_eq!(
462 admin.delete(&root_path("ghost")).await.status,
463 StatusCode::NOT_FOUND
464 );
465 assert_eq!(
466 admin.delete("/api/files/1").await.status,
467 StatusCode::METHOD_NOT_ALLOWED
468 );
469 // DELETE with a trailing-slash root matches no route at all → 404 via
470 // the SPA fallback's API guard.
471 let r = admin.delete("/api/files/1/").await;
472 assert_eq!(r.status, StatusCode::NOT_FOUND);
473 assert_eq!(r.text(), "unknown endpoint");
474}
475
476#[tokio::test]
477async fn upload_creates_files_and_folders() {
478 let env = Env::new().await;
479 let admin = env.admin().await;
480
481 // Single file into the root, nested part name creates the folder.
482 let r = admin
483 .post_multipart(&root_path(""), &[("docs/uploaded.txt", b"up1"), ("new/nested.txt", b"up2")], "")
484 .await;
485 assert_eq!(r.status, StatusCode::OK);
486 assert_eq!(r.json()["uploaded"], 2);
487 assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"up1");
488 assert_eq!(std::fs::read(env.file("new/nested.txt")).unwrap(), b"up2");
489
490 // Conflict: existing file, no overwrite → 409 with the skipped list.
491 let r = admin
492 .post_multipart(&root_path(""), &[("docs/uploaded.txt", b"again")], "")
493 .await;
494 assert_eq!(r.status, StatusCode::CONFLICT);
495 assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"]));
496 assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"up1");
497
498 // Mixed: one conflict + one new file → 409, the new one is uploaded.
499 let r = admin
500 .post_multipart(
501 &root_path(""),
502 &[("docs/uploaded.txt", b"again"), ("fresh.txt", b"new")],
503 "",
504 )
505 .await;
506 assert_eq!(r.status, StatusCode::CONFLICT);
507 assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"]));
508 assert_eq!(r.json()["uploaded"], 1);
509 assert_eq!(std::fs::read(env.file("fresh.txt")).unwrap(), b"new");
510
511 // overwrite=true replaces.
512 let r = admin
513 .post_multipart(&root_path(""), &[("docs/uploaded.txt", b"v3")], "overwrite=true")
514 .await;
515 assert_eq!(r.status, StatusCode::OK);
516 assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"v3");
517
518 // A part name that is an existing directory → 409.
519 let r = admin
520 .post_multipart(&root_path(""), &[("new", b"dir?")], "")
521 .await;
522 assert_eq!(r.status, StatusCode::CONFLICT);
523
524 // Path traversal in a part name → 400.
525 let r = admin
526 .post_multipart(&root_path(""), &[("../evil.txt", b"x")], "")
527 .await;
528 assert!(matches!(
529 r.status,
530 StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN
531 ));
532 assert!(!env.file("../evil.txt").exists());
533 assert!(!env.root.path().parent().unwrap().join("evil.txt").exists());
534
535 // No parts at all → 400.
536 let (ct, body) = multipart_body(&[], "b");
537 let r = admin
538 .raw(axum::http::Method::POST, &root_path(""), &[("content-type", &ct)], body)
539 .await;
540 assert_eq!(r.status, StatusCode::BAD_REQUEST);
541}
542
543#[tokio::test]
544async fn read_only_root_blocks_writes_but_allows_reads() {
545 let env = Env::new().await;
546 let admin = env.admin().await;
547 create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await;
548 let carol = login(&env, "carol", "carolpass1").await;
549 let carol_root_id = carol.get("/api/auth/me").await.json()["roots"][0]["id"]
550 .as_i64()
551 .unwrap();
552
553 // Reads work.
554 let r = carol.get(&format!("/api/files/{carol_root_id}")).await;
555 assert_eq!(r.status, StatusCode::OK);
556 assert!(!r.json()["entries"].as_array().unwrap().is_empty());
557 let r = carol
558 .get(&format!(
559 "/api/files/{carol_root_id}/a.txt?action=download"
560 ))
561 .await;
562 assert_eq!(r.body, b"file a");
563
564 // Writes are blocked.
565 let base = format!("/api/files/{carol_root_id}/x");
566 assert_eq!(
567 carol.raw(axum::http::Method::POST, &base, &[], Vec::new()).await.status,
568 StatusCode::FORBIDDEN
569 );
570 assert_eq!(
571 carol.delete(&format!("/api/files/{carol_root_id}/a.txt")).await.status,
572 StatusCode::FORBIDDEN
573 );
574 assert_eq!(
575 carol
576 .post_json(
577 &format!("/api/files/{carol_root_id}/a.txt"),
578 &json!({ "op": "rename", "new_name": "b.txt" })
579 )
580 .await
581 .status,
582 StatusCode::FORBIDDEN
583 );
584}
585
586#[tokio::test]
587async fn user_cannot_touch_foreign_root() {
588 let env = Env::new().await;
589 let admin = env.admin().await;
590 create_user(&admin, "dave", "davepass12", &[("src", "rw")]).await;
591 let dave = login(&env, "dave", "davepass12").await;
592 let dave_root_id = dave.get("/api/auth/me").await.json()["roots"][0]["id"]
593 .as_i64()
594 .unwrap();
595
596 // His own root works.
597 assert_eq!(
598 dave.get(&format!("/api/files/{dave_root_id}")).await.status,
599 StatusCode::OK
600 );
601 // The admin's root id (1) is not his → 403.
602 assert_eq!(dave.get("/api/files/1").await.status, StatusCode::FORBIDDEN);
603 // Writing into a root he doesn't have → 403.
604 assert_eq!(
605 dave
606 .raw(axum::http::Method::POST, "/api/files/1/evil", &[], Vec::new())
607 .await
608 .status,
609 StatusCode::FORBIDDEN
610 );
611}
612