//! File API: listing, download/preview/content, editor save, mutations, //! upload, access control and path-safety. mod common; use axum::http::StatusCode; use common::*; use serde_json::json; /// Root id for the whole-root (".") user root is 1 (first row inserted). const ROOT: i64 = 1; fn root_path(rel: &str) -> String { // No trailing slash for the bare root: axum's routes are // `/api/files/{root_id}` and `/api/files/{root_id}/{*path}`. if rel.is_empty() { format!("/api/files/{ROOT}") } else { format!("/api/files/{ROOT}/{rel}") } } #[tokio::test] async fn list_root_sorted_folders_first() { let env = Env::new().await; let admin = env.admin().await; let r = admin.get(&root_path("")).await; assert_eq!(r.status, StatusCode::OK); let j = r.json(); let entries = j["entries"].as_array().unwrap(); let names: Vec<&str> = entries .iter() .map(|e| e["name"].as_str().unwrap()) .collect(); assert_eq!( names, vec![ "docs", "src", "blob.bin", "config.json", "editme.txt", "notes.md" ] ); // Entry fields. let docs = &entries[0]; assert_eq!(docs["is_dir"], true); let editme = entries.iter().find(|e| e["name"] == "editme.txt").unwrap(); assert_eq!(editme["is_dir"], false); assert_eq!(editme["size"], 2); assert!(editme["mtime"].as_str().unwrap().ends_with('Z')); } #[tokio::test] async fn list_subdir_and_errors() { let env = Env::new().await; let admin = env.admin().await; let r = admin.get(&root_path("docs")).await; let j = r.json(); let names: Vec<&str> = j .get("entries") .unwrap() .as_array() .unwrap() .iter() .map(|e| e["name"].as_str().unwrap()) .collect(); assert_eq!(names, vec!["inner", "a.txt"]); // Missing path → 404. assert_eq!( admin.get(&root_path("nope")).await.status, StatusCode::NOT_FOUND ); // Listing a file → 400. assert_eq!( admin.get(&root_path("editme.txt")).await.status, StatusCode::BAD_REQUEST ); // Unknown root id → 403. assert_eq!( admin.get("/api/files/999").await.status, StatusCode::FORBIDDEN ); // No session → 401. let anon = Client::new(env.app.clone()); assert_eq!( anon.get(&root_path("")).await.status, StatusCode::UNAUTHORIZED ); } #[tokio::test] async fn path_traversal_is_blocked() { let env = Env::new().await; let admin = env.admin().await; // Encoded `..` segments reach the handler and are rejected. let r = admin.get("/api/files/1/%2e%2e%2f%2e%2e%2fetc").await; assert!( r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND, "traversal returned {:?}", r.status ); // Literal `..` segments: must never succeed. let r = admin.get("/api/files/1/../../etc").await; assert_ne!( r.status, StatusCode::OK, "literal traversal must not be served" ); // Traversal inside a deeper path. let r = admin.get("/api/files/1/docs/..%2f..%2fsrc").await; assert!( r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND, "deep traversal returned {:?}", r.status ); } #[tokio::test] async fn download_single_file() { let env = Env::new().await; let admin = env.admin().await; let r = admin .get(&format!("{}?action=download", root_path("editme.txt"))) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!( r.header("content-disposition").as_deref(), Some("attachment; filename=\"editme.txt\"; filename*=UTF-8''editme.txt") ); assert_eq!(r.header("content-type").as_deref(), Some("text/plain")); assert_eq!(r.body, b"v1"); // Binary content survives. let r = admin .get(&format!("{}?action=download", root_path("blob.bin"))) .await; assert_eq!(r.body, (0..64u8).collect::>()); } #[tokio::test] async fn download_encodes_non_ascii_and_control_characters_in_filename() { let env = Env::new().await; let admin = env.admin().await; std::fs::write(env.file("Übersicht \"q\"\t.txt"), "x").unwrap(); let r = admin .get(&format!( "{}?action=download", root_path("%C3%9Cbersicht%20%22q%22%09.txt") )) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!( r.header("content-disposition").as_deref(), Some( "attachment; filename=\"_bersicht _q__.txt\"; \ filename*=UTF-8''%C3%9Cbersicht%20%22q%22%09.txt" ) ); } #[tokio::test] async fn download_honours_single_byte_ranges() { let env = Env::new().await; let admin = env.admin().await; let url = format!("{}?action=preview", root_path("blob.bin")); let r = admin.get(&url).await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.header("accept-ranges").as_deref(), Some("bytes")); let get = |range: &'static str| { let admin = &admin; let url = url.clone(); async move { admin .raw( axum::http::Method::GET, &url, &[("range", range)], Vec::new(), ) .await } }; let r = get("bytes=10-19").await; assert_eq!(r.status, StatusCode::PARTIAL_CONTENT); assert_eq!(r.header("content-range").as_deref(), Some("bytes 10-19/64")); assert_eq!(r.header("content-length").as_deref(), Some("10")); assert_eq!(r.body, (10..20u8).collect::>()); // A whole-file range is still a 206 with a `Content-Range` (Firefox). let r = get("bytes=0-").await; assert_eq!(r.status, StatusCode::PARTIAL_CONTENT); assert_eq!(r.header("content-range").as_deref(), Some("bytes 0-63/64")); assert_eq!(r.body.len(), 64); // Open end and suffix forms; an end past EOF is clamped. let r = get("bytes=60-").await; assert_eq!(r.body, (60..64u8).collect::>()); let r = get("bytes=-4").await; assert_eq!(r.body, (60..64u8).collect::>()); let r = get("bytes=62-999").await; assert_eq!(r.header("content-range").as_deref(), Some("bytes 62-63/64")); // Out of range, several ranges, or garbage → 416 with the size. for range in ["bytes=64-70", "bytes=0-1,4-5", "items=1-2"] { let r = get(range).await; assert_eq!(r.status, StatusCode::RANGE_NOT_SATISFIABLE, "{range}"); assert_eq!(r.header("content-range").as_deref(), Some("bytes */64")); } } #[tokio::test] async fn download_folder_as_all_archive_formats() { let env = Env::new().await; let admin = env.admin().await; let path = format!("{}?action=download", root_path("docs")); let r = admin.get(&format!("{path}&format=zip")).await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.header("content-type").as_deref(), Some("application/zip")); assert_eq!( r.header("content-disposition").as_deref(), Some("attachment; filename=\"docs.zip\"; filename*=UTF-8''docs.zip") ); let map = zip_map(&r.body); assert_eq!(map.get("docs/a.txt").unwrap(), b"file a"); assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world"); let r = admin.get(&format!("{path}&format=tar")).await; assert_eq!( r.header("content-type").as_deref(), Some("application/x-tar") ); assert_eq!( r.header("content-disposition").as_deref(), Some("attachment; filename=\"docs.tar\"; filename*=UTF-8''docs.tar") ); let map = tar_map(&r.body, Compress::None); assert_eq!(map.get("docs/a.txt").unwrap(), b"file a"); assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world"); let r = admin.get(&format!("{path}&format=tar.gz")).await; assert_eq!( r.header("content-type").as_deref(), Some("application/gzip") ); assert_eq!( r.header("content-disposition").as_deref(), Some("attachment; filename=\"docs.tar.gz\"; filename*=UTF-8''docs.tar.gz") ); let map = tar_map(&r.body, Compress::Gz); assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world"); let r = admin.get(&format!("{path}&format=tar.zst")).await; assert_eq!( r.header("content-type").as_deref(), Some("application/zstd") ); assert_eq!( r.header("content-disposition").as_deref(), Some("attachment; filename=\"docs.tar.zst\"; filename*=UTF-8''docs.tar.zst") ); let map = tar_map(&r.body, Compress::Zst); assert_eq!(map.get("docs/a.txt").unwrap(), b"file a"); } #[tokio::test] async fn download_folder_requires_valid_format() { let env = Env::new().await; let admin = env.admin().await; let path = format!("{}?action=download", root_path("docs")); // No format → 400. assert_eq!(admin.get(&path).await.status, StatusCode::BAD_REQUEST); // Unknown format → 400. assert_eq!( admin.get(&format!("{path}&format=rar")).await.status, StatusCode::BAD_REQUEST ); // Downloading a file with a format is fine (format ignored). let r = admin .get(&format!( "{}?action=download&format=zip", root_path("editme.txt") )) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.body, b"v1"); } #[tokio::test] async fn preview_serves_inline_and_rejects_dirs() { let env = Env::new().await; let admin = env.admin().await; let r = admin .get(&format!("{}?action=preview", root_path("config.json"))) .await; assert_eq!(r.status, StatusCode::OK); assert!( r.header("content-disposition") .unwrap() .starts_with("inline;") ); assert_eq!(r.body, b"{\"k\": 1}"); assert_eq!( admin .get(&format!("{}?action=preview", root_path("docs"))) .await .status, StatusCode::BAD_REQUEST ); } #[tokio::test] async fn content_action_serves_raw_bytes_with_mtime() { let env = Env::new().await; let admin = env.admin().await; let r = admin .get(&format!("{}?action=content", root_path("notes.md"))) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!( r.header("content-type").as_deref(), Some("text/plain; charset=utf-8") ); let mtime = r.header("x-file-mtime").unwrap(); assert!(mtime.parse::().is_ok()); assert_eq!(r.body, b"# notes"); assert_eq!( admin .get(&format!("{}?action=content", root_path("docs"))) .await .status, StatusCode::BAD_REQUEST ); } #[tokio::test] async fn content_is_capped_at_two_mibibytes() { let env = Env::new().await; let admin = env.admin().await; let big = vec![b'x'; 2 * 1024 * 1024 + 1]; std::fs::write(env.file("big.bin"), &big).unwrap(); let r = admin .get(&format!("{}?action=content", root_path("big.bin"))) .await; assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE); // The file itself still downloads fine. let r = admin .get(&format!("{}?action=download", root_path("big.bin"))) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.body.len(), big.len()); } #[tokio::test] async fn editor_save_over_two_mibibytes_is_rejected_with_the_localized_error() { let env = Env::new().await; let admin = env.admin().await; let big = vec![b'x'; 2 * 1024 * 1024 + 1]; let r = admin .put_content( &format!("{}?action=content", root_path("editme.txt")), &big, None, ) .await; assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE); assert_eq!(r.json()["code"], "err_too_large_save"); assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v1"); } #[tokio::test] async fn editor_save_round_trip_and_conflict() { let env = Env::new().await; let admin = env.admin().await; let path = format!("{}?action=content", root_path("editme.txt")); // Read current mtime via the content endpoint. let r = admin.get(&path).await; assert_eq!(r.status, StatusCode::OK); let mtime: i64 = r.header("x-file-mtime").unwrap().parse().unwrap(); // Save with a matching expected mtime. let r = admin.put_content(&path, b"v2", Some(mtime)).await; assert_eq!(r.status, StatusCode::OK); let new_mtime = r.json()["mtime"].as_i64().unwrap(); assert!(new_mtime >= mtime); assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2"); // A stale/wrong expected mtime conflicts (409). Use a value far from the // current mtime so this is deterministic regardless of the filesystem's // timestamp granularity (the mtime may not have advanced after the save). let r = admin.put_content(&path, b"v3", Some(mtime + 999_999)).await; assert_eq!(r.status, StatusCode::CONFLICT); // A conflict must not modify the file. assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2"); // No expected mtime → force save. let r = admin.put_content(&path, b"v4", None).await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v4"); // Saving a missing file → 404; a directory → 400. // (PUT without action=content → 400.) let r = admin .raw( axum::http::Method::PUT, &root_path("editme.txt"), &[("content-type", "text/plain")], b"x".to_vec(), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); let r = admin .put_content( &format!("{}?action=content", root_path("ghost.txt")), b"x", None, ) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); let r = admin .put_content(&format!("{}?action=content", root_path("docs")), b"x", None) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // Oversized body → 413. let r = admin .put_content(&path, &vec![b'a'; 2 * 1024 * 1024 + 1], None) .await; assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE); } #[tokio::test] async fn mkdir_and_rename() { let env = Env::new().await; let admin = env.admin().await; // mkdir names itself with ?action=mkdir. let mkdir_url = |name: &str| format!("{}?action=mkdir", root_path(name)); let r = admin .raw( axum::http::Method::POST, &mkdir_url("newdir"), &[], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::OK); assert!(env.file("newdir").is_dir()); // Duplicate → 409. let r = admin .raw( axum::http::Method::POST, &mkdir_url("newdir"), &[], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::CONFLICT); // Empty name → 400 (bare root POST with JSON op is rejected too). let r = admin .raw(axum::http::Method::POST, &mkdir_url(""), &[], Vec::new()) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // A POST that names no action and carries no known body type is rejected // instead of silently creating a folder. let r = admin .raw( axum::http::Method::POST, &root_path("sneaky"), &[], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::UNSUPPORTED_MEDIA_TYPE); assert!(!env.file("sneaky").exists()); // Rename. let r = admin .post_json( &root_path("editme.txt"), &json!({ "op": "rename", "new_name": "renamed.txt" }), ) .await; assert_eq!(r.status, StatusCode::OK); assert!(env.file("renamed.txt").exists()); // Conflict. let r = admin .post_json( &root_path("renamed.txt"), &json!({ "op": "rename", "new_name": "config.json" }), ) .await; assert_eq!(r.status, StatusCode::CONFLICT); // With overwrite. let r = admin .post_json( &root_path("renamed.txt"), &json!({ "op": "rename", "new_name": "config.json", "overwrite": true }), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("config.json")).unwrap(), b"v1"); // Invalid name. let r = admin .post_json( &root_path("notes.md"), &json!({ "op": "rename", "new_name": "a/b" }), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // Missing source. let r = admin .post_json( &root_path("ghost"), &json!({ "op": "rename", "new_name": "x" }), ) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); // Unknown op: `api_types::Op` has no such variant, so the body fails to // deserialize. `dispatch_inner` parses it itself, so this stays a 400. let r = admin .post_json(&root_path("notes.md"), &json!({ "op": "explode" })) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn move_and_copy_across_dirs() { let env = Env::new().await; let admin = env.admin().await; // Move notes.md into docs/. let r = admin .post_json( &root_path("notes.md"), &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }), ) .await; assert_eq!(r.status, StatusCode::OK); assert!(!env.file("notes.md").exists()); assert_eq!( std::fs::read(env.file("docs/notes.md")).unwrap(), b"# notes" ); // Copy docs/inner back out — as a folder. let r = admin .post_json( &root_path("docs/inner"), &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!( std::fs::read(env.file("src/inner/hello.txt")).unwrap(), b"hello world" ); assert!(env.file("docs/inner/hello.txt").exists()); // Conflict without overwrite, ok with: copy into a folder that already // holds a file with the same name. let r = admin .post_json( &root_path("docs/a.txt"), &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a"); std::fs::write(env.file("docs/a.txt"), "file a2").unwrap(); let r = admin .post_json( &root_path("docs/a.txt"), &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }), ) .await; assert_eq!(r.status, StatusCode::CONFLICT); let r = admin .post_json( &root_path("docs/a.txt"), &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src", "overwrite": true }), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a2"); // Copying an item into its own folder (same path) is a no-op success. let r = admin .post_json( &root_path("docs/a.txt"), &json!({ "op": "copy", "dst_root_id": ROOT, "dst": "docs" }), ) .await; assert_eq!(r.status, StatusCode::OK); // Moving a folder into itself → 400. let r = admin .post_json( &root_path("docs"), &json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // Missing dst_root_id / dst dir. let r = admin .post_json(&root_path("docs/a.txt"), &json!({ "op": "move" })) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); let r = admin .post_json( &root_path("docs/a.txt"), &json!({ "op": "move", "dst_root_id": ROOT, "dst": "no-such-dir" }), ) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); } #[tokio::test] async fn delete_file_and_folder() { let env = Env::new().await; let admin = env.admin().await; let r = admin.delete(&root_path("editme.txt")).await; assert_eq!(r.status, StatusCode::OK); assert!(!env.file("editme.txt").exists()); let r = admin.delete(&root_path("docs")).await; assert_eq!(r.status, StatusCode::OK); assert!(!env.file("docs").exists()); // Missing → 404. A DELETE on the bare root matches no route's method → // 405 (the path only has GET/POST routes). assert_eq!( admin.delete(&root_path("ghost")).await.status, StatusCode::NOT_FOUND ); assert_eq!( admin.delete("/api/files/1").await.status, StatusCode::METHOD_NOT_ALLOWED ); // DELETE with a trailing-slash root matches no route at all → 404 via // the SPA fallback's API guard. let r = admin.delete("/api/files/1/").await; assert_eq!(r.status, StatusCode::NOT_FOUND); assert_eq!(r.text(), "unknown endpoint"); } #[tokio::test] async fn upload_creates_files_and_folders() { let env = Env::new().await; let admin = env.admin().await; // Single file into the root, nested part name creates the folder. let r = admin .post_multipart( &root_path(""), &[("docs/uploaded.txt", b"up1"), ("new/nested.txt", b"up2")], "", ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!( std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"up1" ); assert_eq!(std::fs::read(env.file("new/nested.txt")).unwrap(), b"up2"); // Conflict: existing file, no overwrite → 409 with the skipped list. let r = admin .post_multipart(&root_path(""), &[("docs/uploaded.txt", b"again")], "") .await; assert_eq!(r.status, StatusCode::CONFLICT); assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"])); assert_eq!( std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"up1" ); // Mixed: one conflict + one new file → 409, the new one is uploaded. let r = admin .post_multipart( &root_path(""), &[("docs/uploaded.txt", b"again"), ("fresh.txt", b"new")], "", ) .await; assert_eq!(r.status, StatusCode::CONFLICT); assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"])); assert_eq!(r.json()["uploaded"], 1); assert_eq!(std::fs::read(env.file("fresh.txt")).unwrap(), b"new"); // overwrite=true replaces. let r = admin .post_multipart( &root_path(""), &[("docs/uploaded.txt", b"v3")], "overwrite=true", ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"v3"); // A part name that is an existing directory → 409, and it is named in // `skipped` so the client can fail just that file. Also with // overwrite=true: a folder is never replaced by a file. for query in ["", "overwrite=true"] { let r = admin .post_multipart( &root_path(""), &[("new", b"dir?"), ("beside.txt", b"ok")], query, ) .await; assert_eq!(r.status, StatusCode::CONFLICT); assert_eq!(r.json()["skipped"], json!(["new"])); assert!(env.file("new").is_dir()); std::fs::remove_file(env.file("beside.txt")).unwrap(); } // A quote in the part name: the client percent-escapes it, the server // decodes it back (multer only unescapes backslashes). let r = admin .post_multipart(&root_path(""), &[("qu%22ote.txt", b"q")], "") .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(std::fs::read(env.file("qu\"ote.txt")).unwrap(), b"q"); // Path traversal in a part name → 400. let r = admin .post_multipart(&root_path(""), &[("../evil.txt", b"x")], "") .await; assert!(matches!( r.status, StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN )); assert!(!env.file("../evil.txt").exists()); assert!(!env.root.path().parent().unwrap().join("evil.txt").exists()); // No parts at all → 400. let (ct, body) = multipart_body(&[], "b"); let r = admin .raw( axum::http::Method::POST, &root_path(""), &[("content-type", &ct)], body, ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); } #[cfg(unix)] #[tokio::test] async fn upload_does_not_follow_symlinked_directories_out_of_the_root() { let env = Env::new().await; let admin = env.admin().await; let outside = tempfile::tempdir().unwrap(); std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap(); // Into the linked directory itself, and into a new folder below it. for part in ["link/escaped.txt", "link/deeper/escaped.txt"] { let r = admin .post_multipart(&root_path("docs"), &[(part, b"leak")], "") .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{part}: {}", r.text()); } assert!(!outside.path().join("escaped.txt").exists()); assert!(!outside.path().join("deeper").exists()); assert!( std::fs::read_dir(outside.path()).unwrap().next().is_none(), "no temp file may be left outside the root" ); // A symlink that stays inside the root still works. std::os::unix::fs::symlink(env.file("src"), env.file("docs/inside")).unwrap(); let r = admin .post_multipart(&root_path("docs"), &[("inside/ok.txt", b"fine")], "") .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(std::fs::read(env.file("src/ok.txt")).unwrap(), b"fine"); } #[tokio::test] async fn exists_check_reports_targets_without_creating_anything() { let env = Env::new().await; let admin = env.admin().await; let url = format!("{}?action=exists", root_path("")); let r = admin .post_json( &url, &json!({ "paths": [ "docs/a.txt", "docs", "missing.txt", "nowhere/deep/file.txt", ] }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!( r.json()["existing"], json!([ { "path": "docs/a.txt", "is_dir": false }, { "path": "docs", "is_dir": true }, ]) ); assert!( !env.file("nowhere").exists(), "the check must not create parent folders" ); // Traversal → 400. let r = admin .post_json(&url, &json!({ "paths": ["../evil.txt"] })) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // Read-only roots cannot be uploaded to, so they cannot be checked either. create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await; let carol = login(&env, "carol", "carolpass1").await; let carol_root = carol.get("/api/auth/me").await.json()["roots"][0]["id"] .as_i64() .unwrap(); let r = carol .post_json( &format!("/api/files/{carol_root}?action=exists"), &json!({ "paths": ["a.txt"] }), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); } #[cfg(unix)] #[tokio::test] async fn exists_check_does_not_follow_symlinked_directories_out_of_the_root() { let env = Env::new().await; let admin = env.admin().await; let outside = tempfile::tempdir().unwrap(); std::fs::write(outside.path().join("secret.txt"), b"s").unwrap(); std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap(); for part in ["link/secret.txt", "link/deeper/x.txt"] { let r = admin .post_json( &format!("{}?action=exists", root_path("docs")), &json!({ "paths": [part] }), ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{part}: {}", r.text()); } assert!(!outside.path().join("deeper").exists()); } /// A complete multipart body for one part, streamed in two halves. `between` /// runs after the first half reached the server and before the second is /// sent, so the test can change the disk while the upload is in flight. async fn upload_in_two_halves( env: &Env, admin: &Client, name: &str, between: impl FnOnce() + Send + 'static, ) -> (StatusCode, serde_json::Value) { let mut body: Vec = Vec::new(); body.extend_from_slice( format!("--B\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(), ); body.extend_from_slice(&vec![b'x'; 300 * 1024]); body.extend_from_slice(b"\r\n--B--\r\n"); let half = body.len() / 2; let second: Vec = body.split_off(half); // Three steps: first half, the side effect, second half. let steps: Vec Option> + Send>> = vec![ Box::new(move || Some(body)), Box::new(move || { between(); None }), Box::new(move || Some(second)), ]; let stream = futures_util::stream::unfold(steps.into_iter(), |mut it| async move { loop { let step = it.next()?; match step() { Some(chunk) => { return Some((Ok::<_, std::io::Error>(axum::body::Bytes::from(chunk)), it)); } // Let the server consume the first half before continuing. None => tokio::task::yield_now().await, } } }); let req = axum::http::Request::builder() .method(axum::http::Method::POST) .uri(root_path("")) .header("content-type", "multipart/form-data; boundary=B") .header( "cookie", format!("dovenest_session={}", admin.cookie.as_ref().unwrap()), ) .body(axum::body::Body::from_stream(stream)) .unwrap(); let res = tower::ServiceExt::oneshot(env.app.clone(), req) .await .expect("request"); let status = res.status(); let bytes = http_body_util::BodyExt::collect(res.into_body()) .await .unwrap() .to_bytes(); ( status, serde_json::from_slice(&bytes).unwrap_or(json!(null)), ) } /// The pre-upload stat said "does not exist". A file created while the body /// streams in must still not be replaced: the publish step checks again, /// atomically. #[tokio::test] async fn upload_does_not_clobber_a_file_created_during_the_transfer() { let env = Env::new().await; let admin = env.admin().await; let target = env.file("raced.txt"); assert!(!target.exists()); let t = target.clone(); let (status, body) = upload_in_two_halves(&env, &admin, "raced.txt", move || { std::fs::write(&t, b"someone else").unwrap(); }) .await; assert_eq!(status, StatusCode::CONFLICT, "{body}"); assert_eq!(body["skipped"], json!(["raced.txt"])); assert_eq!(std::fs::read(&target).unwrap(), b"someone else"); assert!( !entries(&env).iter().any(|n| n.starts_with(".upload-")), "scratch file left behind: {:?}", entries(&env) ); } /// A multipart body that stops inside a part: the headers and part of the /// payload, then end of stream with no closing boundary. That is what reaches /// the server when the user closes the tab or the connection drops. async fn upload_stopped_mid_part(env: &Env, admin: &Client) -> StatusCode { let mut body: Vec = Vec::new(); body.extend_from_slice( b"--B\r\nContent-Disposition: form-data; name=\"interrupted.txt\"\r\n\r\n", ); body.extend_from_slice(&vec![b'x'; 300 * 1024]); let stream = futures_util::stream::unfold(body, |mut rest| async move { if rest.len() > 1024 { let n = rest.len() / 2; let chunk: Vec = rest.drain(..n).collect(); Some(( Ok::<_, std::io::Error>(axum::body::Bytes::from(chunk)), rest, )) } else { None // EOF: the terminating boundary never arrives } }); let req = axum::http::Request::builder() .method(axum::http::Method::POST) .uri(root_path("")) .header("content-type", "multipart/form-data; boundary=B") .header( "cookie", format!("dovenest_session={}", admin.cookie.as_ref().unwrap()), ) .body(axum::body::Body::from_stream(stream)) .unwrap(); let res = tower::ServiceExt::oneshot(env.app.clone(), req) .await .expect("request"); let status = res.status(); let _ = http_body_util::BodyExt::collect(res.into_body()).await; status } /// Every entry name in the server root, hidden ones included. fn entries(env: &Env) -> Vec { std::fs::read_dir(env.root.path()) .unwrap() .flatten() .map(|e| e.file_name().to_string_lossy().into_owned()) .collect() } /// An upload is streamed to `.upload-` and renamed into place. A part /// that never reaches the rename must take the scratch file with it. Nothing /// ever names that file again, and listings show it. #[tokio::test] async fn an_interrupted_upload_leaves_no_scratch_file() { let env = Env::new().await; let admin = env.admin().await; let status = upload_stopped_mid_part(&env, &admin).await; assert!( status.is_client_error(), "expected a rejection, got {status}" ); assert!( !entries(&env).iter().any(|n| n.starts_with(".upload-")), "scratch file left behind: {:?}", entries(&env) ); assert!(!env.file("interrupted.txt").exists()); // The same assertion after a completed upload, so a guard that never // disarms cannot pass this test by accident. let r = admin .post_multipart(&root_path(""), &[("finished.txt", b"whole")], "") .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(std::fs::read(env.file("finished.txt")).unwrap(), b"whole"); assert!( !entries(&env).iter().any(|n| n.starts_with(".upload-")), "scratch file left after a completed upload: {:?}", entries(&env) ); } #[tokio::test] async fn read_only_root_blocks_writes_but_allows_reads() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await; let carol = login(&env, "carol", "carolpass1").await; let carol_root_id = carol.get("/api/auth/me").await.json()["roots"][0]["id"] .as_i64() .unwrap(); // Reads work. let r = carol.get(&format!("/api/files/{carol_root_id}")).await; assert_eq!(r.status, StatusCode::OK); assert!(!r.json()["entries"].as_array().unwrap().is_empty()); let r = carol .get(&format!("/api/files/{carol_root_id}/a.txt?action=download")) .await; assert_eq!(r.body, b"file a"); // Writes are blocked. let base = format!("/api/files/{carol_root_id}/x?action=mkdir"); assert_eq!( carol .raw(axum::http::Method::POST, &base, &[], Vec::new()) .await .status, StatusCode::FORBIDDEN ); assert_eq!( carol .delete(&format!("/api/files/{carol_root_id}/a.txt")) .await .status, StatusCode::FORBIDDEN ); assert_eq!( carol .post_json( &format!("/api/files/{carol_root_id}/a.txt"), &json!({ "op": "rename", "new_name": "b.txt" }) ) .await .status, StatusCode::FORBIDDEN ); } #[tokio::test] async fn user_cannot_touch_foreign_root() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "dave", "davepass12", &[("src", "rw")]).await; let dave = login(&env, "dave", "davepass12").await; let dave_root_id = dave.get("/api/auth/me").await.json()["roots"][0]["id"] .as_i64() .unwrap(); // His own root works. assert_eq!( dave.get(&format!("/api/files/{dave_root_id}")).await.status, StatusCode::OK ); // The admin's root id (1) is not his → 403. assert_eq!(dave.get("/api/files/1").await.status, StatusCode::FORBIDDEN); // Writing into a root he doesn't have → 403. assert_eq!( dave.raw( axum::http::Method::POST, "/api/files/1/evil?action=mkdir", &[], Vec::new() ) .await .status, StatusCode::FORBIDDEN ); } /// Listings report a content-sniffed `kind`, not an extension guess. #[tokio::test] async fn listing_reports_sniffed_kinds() { let env = Env::new().await; let admin = env.admin().await; // A PNG named .txt and a text file named .png: the bytes must win. std::fs::write( env.file("lies.txt"), [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A], ) .unwrap(); std::fs::write(env.file("lies.png"), "just words\n").unwrap(); std::fs::write(env.file("report.html"), "

hi").unwrap(); std::fs::write(env.file("noext"), "plain text, no extension\n").unwrap(); let r = admin.get(&root_path("")).await; assert_eq!(r.status, StatusCode::OK); let j = r.json(); let kind = |name: &str| -> String { j["entries"] .as_array() .unwrap() .iter() .find(|e| e["name"] == name) .unwrap_or_else(|| panic!("{name} missing from listing"))["kind"] .as_str() .unwrap() .to_string() }; assert_eq!(kind("lies.txt"), "image"); assert_eq!(kind("lies.png"), "text"); assert_eq!(kind("report.html"), "text"); assert_eq!(kind("noext"), "text"); assert_eq!(kind("blob.bin"), "binary"); assert_eq!(kind("docs"), "dir"); assert_eq!(kind("config.json"), "text"); } /// A file the browser would parse as a document is served sandboxed, so it /// can render as a page without being able to act as the app. Scriptable /// files are never frameable; non-scriptable previews are frameable by the /// app itself only. #[tokio::test] async fn scriptable_files_are_served_sandboxed() { let env = Env::new().await; let admin = env.admin().await; std::fs::write(env.file("page.html"), "

hi").unwrap(); std::fs::write( env.file("logo.svg"), "", ) .unwrap(); for name in ["page.html", "logo.svg"] { let r = admin .get(&format!("{}?action=preview", root_path(name))) .await; assert_eq!(r.status, StatusCode::OK); let csp = r.header("content-security-policy").unwrap(); assert!(csp.contains("sandbox "), "{name} not sandboxed: {csp}"); assert!(csp.contains("allow-scripts"), "{name}: {csp}"); // The whole security property: an opaque origin. assert!( !csp.contains("allow-same-origin"), "{name} must never get allow-same-origin: {csp}" ); assert!( !csp.contains("allow-top-navigation ") && !csp.contains("allow-popups-to-escape"), "{name}: {csp}" ); // Still rendered as a document, not downloaded. assert!( r.header("content-disposition") .unwrap() .starts_with("inline") ); // Never frameable: same-origin framing would give its JS access to // the app. assert!( csp.contains("frame-ancestors 'none'"), "{name} must never be frameable: {csp}" ); assert_eq!( r.header("x-frame-options").as_deref(), Some("DENY"), "{name}" ); } // A non-scriptable preview is frameable by the app itself only. let r = admin .get(&format!("{}?action=preview", root_path("blob.bin"))) .await; let csp = r.header("content-security-policy").unwrap(); assert!(!csp.contains("sandbox"), "{csp}"); assert!( csp.contains("frame-ancestors 'self'"), "preview must be frameable same-origin: {csp}" ); assert_eq!(r.header("x-frame-options").as_deref(), Some("SAMEORIGIN")); // The same file as a *download* keeps the app policy (unframeable). let r = admin .get(&format!("{}?action=download", root_path("blob.bin"))) .await; let csp = r.header("content-security-policy").unwrap(); assert!( csp.contains("frame-ancestors 'none'"), "download must keep the app policy: {csp}" ); assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY")); // And the app's own pages are untouched by the `if_not_present` switch. let r = admin.get("/").await; let csp = r.header("content-security-policy").unwrap(); assert!( csp.contains("wasm-unsafe-eval") && !csp.contains("sandbox"), "{csp}" ); assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY")); } #[tokio::test] async fn archive_does_not_follow_symlinks_out_of_the_root() { let env = Env::new().await; let admin = env.admin().await; // A directory outside the served root, linked to from inside it. let outside = tempfile::tempdir().unwrap(); std::fs::write(outside.path().join("secret.txt"), "leaked").unwrap(); std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap(); let r = admin .get(&format!("{}?action=download&format=tar", root_path("docs"))) .await; assert_eq!(r.status, StatusCode::OK); let map = tar_map(&r.body, Compress::None); assert!( !map.keys().any(|k| k.contains("secret.txt")), "archive escaped the root: {:?}", map.keys().collect::>() ); // The legitimate entries are still there. assert_eq!(map.get("docs/a.txt").unwrap(), b"file a"); assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world"); } #[tokio::test] async fn listing_is_paged_in_the_requested_order() { let env = Env::new().await; let admin = env.admin().await; let dir = env.file("paged"); std::fs::create_dir_all(dir.join("sub")).unwrap(); for i in 0..25 { std::fs::write(dir.join(format!("f{i:02}")), vec![b'x'; i]).unwrap(); } let names = |j: &serde_json::Value| -> Vec { j["entries"] .as_array() .unwrap() .iter() .map(|e| e["name"].as_str().unwrap().to_string()) .collect() }; // No params: the whole folder by name, folders first. let j = admin.get(&root_path("paged")).await.json(); assert_eq!( (j["total"].as_u64(), j["offset"].as_u64()), (Some(26), Some(0)) ); assert_eq!(names(&j).len(), 26); assert_eq!(names(&j)[0], "sub"); let r = admin .get(&format!( "{}?sort=size&desc=true&offset=10&limit=10", root_path("paged") )) .await; assert_eq!(r.status, StatusCode::OK); let j = r.json(); assert_eq!( (j["total"].as_u64(), j["offset"].as_u64()), (Some(26), Some(10)) ); // Index 0 is "sub", then f24 down to f00. let want: Vec = (6..=15).rev().map(|i| format!("f{i:02}")).collect(); assert_eq!(names(&j), want); // An offset past the end returns the last page. let j = admin .get(&format!("{}?offset=999&limit=10", root_path("paged"))) .await .json(); assert_eq!(j["offset"].as_u64(), Some(20)); assert_eq!(names(&j).len(), 6); let j = admin .get(&format!("{}?dirs=true", root_path("paged"))) .await .json(); assert_eq!(names(&j), ["sub"]); assert_eq!(j["total"].as_u64(), Some(1)); } #[tokio::test] async fn download_revalidates_with_last_modified() { let env = Env::new().await; let admin = env.admin().await; let path = format!("{}?action=download", root_path("editme.txt")); let file = env.root.path().join("editme.txt"); // A file written in the last two seconds gets no validator. Pin the mtime // to "now" first: the fixture is written during `Env` setup, which under a // loaded parallel run can take longer than that two-second window. std::fs::File::options() .write(true) .open(&file) .unwrap() .set_modified(std::time::SystemTime::now()) .unwrap(); let r = admin.get(&path).await; assert_eq!(r.status, StatusCode::OK); assert!(r.header("last-modified").is_none()); // Nor a 304, whatever date the client sends: a second write in the same // second would go unseen. let r = admin .raw( axum::http::Method::GET, &path, &[("if-modified-since", "Fri, 01 Jan 2100 00:00:00 GMT")], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.body, b"v1"); // No validator here, so the policy matters more: with no Cache-Control a // shared cache may apply heuristic freshness. assert_eq!( r.header("cache-control").as_deref(), Some("private, no-cache"), "a file response always carries a caching policy" ); // Backdate the file so the validator appears. let f = std::fs::File::options().write(true).open(&file).unwrap(); f.set_modified( std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000), ) .unwrap(); let r = admin.get(&path).await; assert_eq!(r.status, StatusCode::OK); assert_eq!( r.header("cache-control").as_deref(), Some("private, no-cache") ); let lm = r.header("last-modified").expect("Last-Modified header"); let r = admin .raw( axum::http::Method::GET, &path, &[("if-modified-since", lm.as_str())], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::NOT_MODIFIED); assert!(r.body.is_empty()); // The refresh repeats the policy, so the stored entry does not lose it. assert_eq!( r.header("cache-control").as_deref(), Some("private, no-cache") ); } /// An mtime before 1970 has no HTTP date. The file is still served, whole /// and without a validator. #[tokio::test] async fn file_dated_before_1970_is_served() { let env = Env::new().await; let admin = env.admin().await; std::fs::File::options() .write(true) .open(env.file("editme.txt")) .unwrap() .set_modified(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)) .unwrap(); for action in ["download", "preview"] { let r = admin .raw( axum::http::Method::GET, &format!("{}?action={action}", root_path("editme.txt")), &[("range", "bytes=0-0")], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::OK, "{action}"); assert_eq!(r.body, b"v1", "{action}"); assert!(r.header("last-modified").is_none(), "{action}"); assert_eq!( r.header("cache-control").as_deref(), Some("private, no-cache") ); } } /// The browser copies a 304's CSP onto the cached response, so a revalidated /// file must keep the policy its 200 had, not get the app's. #[tokio::test] async fn revalidation_keeps_the_file_policy() { let env = Env::new().await; let admin = env.admin().await; std::fs::write(env.file("page.html"), "

hi").unwrap(); for name in ["page.html", "blob.bin"] { std::fs::File::options() .write(true) .open(env.file(name)) .unwrap() .set_modified( std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000), ) .unwrap(); let path = format!("{}?action=preview", root_path(name)); let first = admin.get(&path).await; let lm = first.header("last-modified").expect("Last-Modified header"); let r = admin .raw( axum::http::Method::GET, &path, &[("if-modified-since", lm.as_str())], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::NOT_MODIFIED, "{name}"); assert_eq!( r.header("content-security-policy"), first.header("content-security-policy"), "{name}" ); assert_eq!( r.header("x-frame-options"), first.header("x-frame-options"), "{name}" ); } } // --------------------------------------------------------------------------- // Symlinks: an operation on a name acts on the entry, not on what it points at // --------------------------------------------------------------------------- /// Create `link` inside the root, pointing at `target`. fn symlink(env: &Env, target: &std::path::Path, link: &str) { std::os::unix::fs::symlink(target, env.file(link)).unwrap(); } #[tokio::test] async fn deleting_a_symlink_removes_the_link_not_its_target() { let env = Env::new().await; let admin = env.admin().await; symlink(&env, &env.file("notes.md"), "alias.md"); let r = admin.delete("/api/files/1/alias.md").await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert!(env.file("alias.md").symlink_metadata().is_err()); assert_eq!( std::fs::read_to_string(env.file("notes.md")).unwrap(), "# notes", "the delete followed the link" ); } #[tokio::test] async fn a_dangling_symlink_can_be_deleted() { let env = Env::new().await; let admin = env.admin().await; symlink(&env, &env.file("gone.txt"), "dangling.md"); // Resolving strictly reports "not found", which would leave the link // undeletable through the API. let r = admin.delete("/api/files/1/dangling.md").await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert!(env.file("dangling.md").symlink_metadata().is_err()); } #[tokio::test] async fn renaming_a_symlink_renames_the_link() { let env = Env::new().await; let admin = env.admin().await; symlink(&env, &env.file("docs/a.txt"), "alias.txt"); let r = admin .post_json( "/api/files/1/alias.txt", &json!({ "op": "rename", "new_name": "renamed.txt" }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); // The link moved. Following it would have renamed the target, and into // the target's own directory at that. assert!( env.file("renamed.txt") .symlink_metadata() .unwrap() .file_type() .is_symlink() ); assert!(env.file("docs/a.txt").exists()); assert!(!env.file("docs/renamed.txt").exists()); } #[tokio::test] async fn moving_a_symlink_moves_the_link() { let env = Env::new().await; let admin = env.admin().await; symlink(&env, &env.file("notes.md"), "alias.md"); let r = admin .post_json( "/api/files/1/alias.md", &json!({ "op": "move", "dst_root_id": 1, "dst": "docs" }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert!( env.file("docs/alias.md") .symlink_metadata() .unwrap() .file_type() .is_symlink() ); assert!(env.file("notes.md").exists(), "the move followed the link"); } #[tokio::test] async fn copying_onto_a_symlink_replaces_it() { let env = Env::new().await; let admin = env.admin().await; // A link inside the root aimed outside it. `std::fs::copy` follows a // destination symlink, so without unlinking it first the write lands // outside the root with every path check passing. let outside = env.root.path().parent().unwrap().join("outside.txt"); std::fs::write(&outside, "SECRET").unwrap(); std::fs::create_dir_all(env.file("dest")).unwrap(); std::os::unix::fs::symlink(&outside, env.file("dest/notes.md")).unwrap(); let r = admin .post_json( "/api/files/1/notes.md", &json!({ "op": "copy", "dst_root_id": 1, "dst": "dest", "overwrite": true }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!( std::fs::read_to_string(&outside).unwrap(), "SECRET", "the copy escaped the root" ); assert_eq!( std::fs::read_to_string(env.file("dest/notes.md")).unwrap(), "# notes" ); assert!( !env.file("dest/notes.md") .symlink_metadata() .unwrap() .file_type() .is_symlink() ); } #[tokio::test] async fn copying_a_symlink_copies_what_it_points_at() { let env = Env::new().await; let admin = env.admin().await; symlink(&env, &env.file("notes.md"), "alias.md"); std::fs::create_dir_all(env.file("dest")).unwrap(); // The source is followed on purpose: a copy wants the bytes, like `cp`. let r = admin .post_json( "/api/files/1/alias.md", &json!({ "op": "copy", "dst_root_id": 1, "dst": "dest" }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!( std::fs::read_to_string(env.file("dest/alias.md")).unwrap(), "# notes" ); } #[tokio::test] async fn a_symlink_out_of_the_root_still_cannot_be_read_or_written() { let env = Env::new().await; let admin = env.admin().await; let outside = env.root.path().parent().unwrap().join("outside.txt"); std::fs::write(&outside, "SECRET").unwrap(); std::os::unix::fs::symlink(&outside, env.file("escape.txt")).unwrap(); // Reads and content writes do follow a link, so containment rests on // `ensure_within` rejecting one that leaves the root. let r = admin.get("/api/files/1/escape.txt?action=content").await; assert!(r.status.is_client_error(), "{}", r.status); assert_ne!(r.text(), "SECRET"); let r = admin .put_content("/api/files/1/escape.txt?action=content", b"payload", None) .await; assert!(r.status.is_client_error(), "{}", r.status); assert_eq!(std::fs::read_to_string(&outside).unwrap(), "SECRET"); // Deleting the link is fine: that touches only the entry inside the root. let r = admin.delete("/api/files/1/escape.txt").await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!(std::fs::read_to_string(&outside).unwrap(), "SECRET"); }