//! Integration tests for the jailed file manager. //! //! Each test runs in a real tempdir on the host filesystem. The jail-escape //! tests are the security-critical section: any path that resolves outside the //! instance root MUST be rejected regardless of how the escape is attempted. //! //! Coverage: //! - Functional: list, write, read roundtrip, mkdir, rename, delete //! - Security: dotdot traversal, absolute path injection, symlink escape //! (POSIX symlinks only — `#[cfg(unix)]`) use corrosion_host_agent::filemanager; use std::path::Path; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Create a temporary directory and return its path. The directory is /// automatically cleaned up when the `TempDir` is dropped. fn tempdir() -> tempfile::TempDir { tempfile::tempdir().expect("create tempdir") } // --------------------------------------------------------------------------- // Functional tests // --------------------------------------------------------------------------- #[test] fn write_read_roundtrip() { let dir = tempdir(); let root = dir.path(); let content = "hello from the file manager\nline 2\n"; filemanager::write(root, "test.txt", content).expect("write should succeed"); let got = filemanager::read(root, "test.txt").expect("read should succeed"); assert_eq!(got, content); } #[test] fn list_returns_written_file() { let dir = tempdir(); let root = dir.path(); filemanager::write(root, "server.cfg", "hostname MyServer\n").expect("write"); let entries = filemanager::list(root, "").expect("list root"); let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); assert!(names.contains(&"server.cfg"), "expected 'server.cfg' in listing, got {names:?}"); } #[test] fn list_empty_root_is_empty() { let dir = tempdir(); let entries = filemanager::list(dir.path(), "").expect("list empty root"); assert!(entries.is_empty(), "fresh tempdir should have no entries"); } #[test] fn mkdir_creates_directory() { let dir = tempdir(); let root = dir.path(); filemanager::mkdir(root, "cfg/custom").expect("mkdir should succeed"); assert!(root.join("cfg/custom").is_dir(), "directory should exist after mkdir"); } #[test] fn mkdir_creates_nested_dirs() { let dir = tempdir(); let root = dir.path(); filemanager::mkdir(root, "a/b/c/d").expect("mkdir nested"); assert!(root.join("a/b/c/d").is_dir()); } #[test] fn write_creates_parent_dirs() { let dir = tempdir(); let root = dir.path(); filemanager::write(root, "subdir/deep/file.txt", "data").expect("write with auto-mkdir"); let content = filemanager::read(root, "subdir/deep/file.txt").expect("read"); assert_eq!(content, "data"); } #[test] fn rename_file() { let dir = tempdir(); let root = dir.path(); filemanager::write(root, "old.txt", "content").expect("write"); filemanager::rename(root, "old.txt", "new.txt").expect("rename"); assert!(!root.join("old.txt").exists(), "old.txt should be gone"); assert!(root.join("new.txt").exists(), "new.txt should exist"); let content = filemanager::read(root, "new.txt").expect("read renamed"); assert_eq!(content, "content"); } #[test] fn rename_rejects_separator_in_new_name() { let dir = tempdir(); let root = dir.path(); filemanager::write(root, "file.txt", "data").expect("write"); let err = filemanager::rename(root, "file.txt", "subdir/escape.txt") .expect_err("rename with path separator must fail"); assert!( err.to_string().contains("separator"), "error should mention separator: {err}" ); } #[test] fn delete_file() { let dir = tempdir(); let root = dir.path(); filemanager::write(root, "todelete.txt", "bye").expect("write"); assert!(root.join("todelete.txt").exists()); filemanager::delete(root, "todelete.txt").expect("delete"); assert!(!root.join("todelete.txt").exists()); } #[test] fn delete_directory_recursive() { let dir = tempdir(); let root = dir.path(); filemanager::mkdir(root, "tree/sub").expect("mkdir"); filemanager::write(root, "tree/sub/file.txt", "x").expect("write"); assert!(root.join("tree").is_dir()); filemanager::delete(root, "tree").expect("delete tree"); assert!(!root.join("tree").exists(), "directory tree should be deleted"); } #[test] fn mkfile_creates_empty_file() { let dir = tempdir(); let root = dir.path(); filemanager::mkfile(root, "empty.txt").expect("mkfile"); let content = filemanager::read(root, "empty.txt").expect("read empty file"); assert_eq!(content, ""); } #[test] fn copy_file() { let dir = tempdir(); let root = dir.path(); filemanager::write(root, "source.txt", "original").expect("write source"); filemanager::copy(root, "source.txt", "dest.txt").expect("copy"); let src = filemanager::read(root, "source.txt").expect("read source after copy"); let dst = filemanager::read(root, "dest.txt").expect("read destination"); assert_eq!(src, "original"); assert_eq!(dst, "original"); } #[test] fn move_file() { let dir = tempdir(); let root = dir.path(); filemanager::write(root, "moveme.txt", "payload").expect("write"); filemanager::move_path(root, "moveme.txt", "moved.txt").expect("move"); assert!(!root.join("moveme.txt").exists(), "source should be gone"); let content = filemanager::read(root, "moved.txt").expect("read after move"); assert_eq!(content, "payload"); } #[test] fn list_entry_fields_are_populated() { let dir = tempdir(); let root = dir.path(); filemanager::write(root, "check.txt", "abcde").expect("write"); filemanager::mkdir(root, "subdir").expect("mkdir"); let entries = filemanager::list(root, "").expect("list"); // Dirs sort before files. let dir_entry = entries.iter().find(|e| e.name == "subdir").expect("subdir entry"); assert!(dir_entry.is_dir); assert_eq!(dir_entry.size, 0); assert!(!dir_entry.modified.is_empty(), "modified should be set"); let file_entry = entries.iter().find(|e| e.name == "check.txt").expect("file entry"); assert!(!file_entry.is_dir); assert_eq!(file_entry.size, 5, "size should match byte count"); // path should be relative and use forward slashes. assert!(!file_entry.path.starts_with('/'), "path should be relative"); assert!(!file_entry.path.contains('\\'), "path should use forward slashes"); } // --------------------------------------------------------------------------- // Security: jail-escape tests // CRITICAL — these are the whole point of the jail abstraction. // --------------------------------------------------------------------------- /// `../../etc/passwd` must never resolve outside the instance root. #[test] fn jail_rejects_dotdot_traversal() { let dir = tempdir(); let root = dir.path(); let err = filemanager::read(root, "../../etc/passwd") .expect_err("dotdot traversal must be rejected"); // Verify the error is security-related and not just "file not found". let msg = err.to_string(); assert!( msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), "error should mention jail escape for dotdot traversal, got: {msg}" ); } /// A deeply nested `../` chain must also be stopped. #[test] fn jail_rejects_deep_dotdot_traversal() { let dir = tempdir(); let root = dir.path(); let err = filemanager::read(root, "a/b/c/../../../../../../../../etc/shadow") .expect_err("deep dotdot traversal must be rejected"); let msg = err.to_string(); assert!( msg.contains("outside") || msg.contains("escapes") || msg.contains("escape") || msg.contains("absolute"), "error should mention jail escape for deep traversal, got: {msg}" ); } /// An absolute path (e.g. `/etc/passwd`) must be rejected immediately — it /// completely bypasses relative joining and should never be accepted. #[test] fn jail_rejects_absolute_path() { let dir = tempdir(); let root = dir.path(); let err = filemanager::read(root, "/etc/passwd") .expect_err("absolute path must be rejected"); let msg = err.to_string(); assert!( msg.contains("absolute") || msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), "error should mention the absolute-path rejection, got: {msg}" ); } /// An absolute path to a Windows-style location must also be rejected. #[test] fn jail_rejects_absolute_windows_style_path() { let dir = tempdir(); let root = dir.path(); // On POSIX this is just treated as an absolute path starting with `/`. // The test is intentionally platform-portable: any absolute path is bad. let err = filemanager::read(root, "/tmp/evil") .expect_err("absolute /tmp/evil must be rejected"); let msg = err.to_string(); assert!( msg.contains("absolute") || msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), "got: {msg}" ); } /// A symlink inside the root that points to a path outside the root must not /// be followed. This is the critical symlink-escape vector. #[cfg(unix)] #[test] fn jail_rejects_symlink_escape() { let dir = tempdir(); let root = dir.path(); // Create a directory outside the root to be the symlink target. let outside = tempdir(); let outside_file = outside.path().join("secret.txt"); std::fs::write(&outside_file, "secret data").expect("write outside file"); // Plant a symlink inside the root pointing to the outside directory. let link_path = root.join("evil_link"); std::os::unix::fs::symlink(outside.path(), &link_path) .expect("create symlink inside root"); // Attempt to read through the symlink. let err = filemanager::read(root, "evil_link/secret.txt") .expect_err("symlink escape must be rejected"); let msg = err.to_string(); assert!( msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), "error should mention jail escape for symlink traversal, got: {msg}" ); } /// A symlink directly inside the root pointing to a file outside must be /// rejected even when the path looks like a normal relative reference. #[cfg(unix)] #[test] fn jail_rejects_symlink_pointing_directly_outside() { let dir = tempdir(); let root = dir.path(); // Symlink to /etc/passwd itself (or any outside path that exists or not). let link_path = root.join("passwd_link"); std::os::unix::fs::symlink(Path::new("/etc/passwd"), &link_path) .expect("create symlink to /etc/passwd"); let err = filemanager::read(root, "passwd_link") .expect_err("direct symlink outside root must be rejected"); let msg = err.to_string(); assert!( msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), "error should mention jail escape, got: {msg}" ); } /// A symlink chain (symlink → symlink → outside) must also be caught. #[cfg(unix)] #[test] fn jail_rejects_chained_symlink_escape() { let dir = tempdir(); let root = dir.path(); let outside = tempdir(); // Chain: root/link1 → root/link2 → outside/ let link2_path = root.join("link2"); std::os::unix::fs::symlink(outside.path(), &link2_path) .expect("create link2"); let link1_path = root.join("link1"); std::os::unix::fs::symlink(&link2_path, &link1_path) .expect("create link1"); let err = filemanager::read(root, "link1") .expect_err("chained symlink escape must be rejected"); let msg = err.to_string(); assert!( msg.contains("outside") || msg.contains("escapes") || msg.contains("escape"), "chained symlink should be caught, got: {msg}" ); } // --------------------------------------------------------------------------- // Dispatch layer tests // --------------------------------------------------------------------------- #[test] fn dispatch_list_returns_success() { let dir = tempdir(); let root = dir.path(); filemanager::write(root, "a.txt", "a").expect("write"); let req = filemanager::FileRequest { op: "list".to_string(), path: String::new(), dest: None, content: None, name: None, }; let resp = filemanager::dispatch(root, &req); assert_eq!(resp["status"], "success"); assert!(resp["data"]["entries"].is_array()); } #[test] fn dispatch_unknown_op_returns_error() { let dir = tempdir(); let req = filemanager::FileRequest { op: "explode".to_string(), path: String::new(), dest: None, content: None, name: None, }; let resp = filemanager::dispatch(dir.path(), &req); assert_eq!(resp["status"], "error"); assert!(resp["message"].as_str().unwrap().contains("unknown op")); } #[test] fn dispatch_escape_attempt_returns_error_not_panic() { let dir = tempdir(); let req = filemanager::FileRequest { op: "read".to_string(), path: "../../etc/passwd".to_string(), dest: None, content: None, name: None, }; let resp = filemanager::dispatch(dir.path(), &req); // Must return an error response, not panic or expose the file. assert_eq!(resp["status"], "error", "escape attempt should return error status"); assert!( resp["message"].as_str().is_some(), "error response must have a message" ); }