Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,30 @@
[package]
name = "nomifun-file"
version.workspace = true
edition.workspace = true
[dependencies]
nomifun-api-types.workspace = true
nomifun-common.workspace = true
nomifun-realtime.workspace = true
async-trait.workspace = true
axum.workspace = true
tokio.workspace = true
tower-http.workspace = true
ignore.workspace = true
notify.workspace = true
zip.workspace = true
git2.workspace = true
reqwest.workspace = true
mime_guess.workspace = true
base64.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
dashmap.workspace = true
dirs.workspace = true
[dev-dependencies]
tempfile = "3"
base64.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
@@ -0,0 +1,460 @@
//! Shallow, WebUI-only directory browser backing `GET /api/fs/browse`.
//!
//! Unlike the workspace-scoped `/api/fs/dir` endpoint, this handler lists a
//! single directory level and surfaces navigation hints (`can_go_up`,
//! `parent_path`) plus a `__ROOT__` sentinel for the Windows drive picker.
//! It is only reachable in WebUI deployments; the Electron desktop path uses
//! the native OS dialog and never hits this route.
//!
//! Allowed roots intentionally widen to `cwd` + `home` + (on Windows) every
//! available drive letter + (on Unix) `/`, matching the pre-M6 Express
//! implementation that this replaces.
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use nomifun_api_types::{BrowseDirectoryResponse, BrowseEntry};
use nomifun_common::AppError;
/// Sentinel returned as `parent_path` on Windows drive roots, signaling the
/// frontend to navigate back to the drive-list screen.
pub const ROOT_SENTINEL: &str = "__ROOT__";
/// Upper bound on directory entries returned per call. Large directories are
/// truncated to keep the response cheap to render.
pub const MAX_BROWSE_ITEMS: usize = 500;
// ---------------------------------------------------------------------------
// Allowed-root resolution
// ---------------------------------------------------------------------------
/// Build the allow-list of roots that `/api/fs/browse` may traverse.
///
/// Returns canonicalized paths; callers compare against these with
/// `Path::starts_with`. Duplicates and unreadable entries are silently
/// dropped.
pub fn default_browse_roots() -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
roots.push(cwd);
}
if let Some(home) = dirs::home_dir() {
roots.push(home);
}
#[cfg(windows)]
{
roots.extend(enumerate_windows_drives());
}
#[cfg(unix)]
{
// Widest possible sandbox on Unix — the pre-M6 Express endpoint
// allowed `/`, and the WebUI host-files use case genuinely needs to
// reach outside $HOME (e.g. `/Volumes/*` on macOS).
roots.push(PathBuf::from("/"));
}
let mut canonical: Vec<PathBuf> = roots
.into_iter()
.filter_map(|p| fs::canonicalize(&p).ok().or(Some(p)))
.collect();
canonical.sort();
canonical.dedup();
canonical
}
#[cfg(windows)]
fn enumerate_windows_drives() -> Vec<PathBuf> {
let mut drives = Vec::new();
for letter in b'A'..=b'Z' {
let path = PathBuf::from(format!("{}:\\", letter as char));
if path.is_dir() {
drives.push(path);
}
}
drives
}
/// Produce the Windows drive-list screen response.
#[cfg(windows)]
pub fn drive_list_response() -> BrowseDirectoryResponse {
let items = enumerate_windows_drives()
.into_iter()
.map(|drive| {
let letter = drive.to_string_lossy().chars().next().unwrap_or('?');
BrowseEntry {
name: format!("{letter}:"),
path: drive.to_string_lossy().into_owned(),
is_directory: true,
is_file: false,
size: None,
modified: None,
}
})
.collect();
BrowseDirectoryResponse {
current_path: String::new(),
parent_path: None,
items,
can_go_up: false,
truncated: false,
is_root: Some(true),
}
}
// ---------------------------------------------------------------------------
// Path validation
// ---------------------------------------------------------------------------
/// Canonicalize `raw` and verify it lives under one of the allowed roots.
///
/// `~` expansion is handled explicitly so users can paste `~/Documents`
/// into the picker. Symlinks are resolved via `canonicalize` before the
/// sandbox check, so a link pointing outside the allow-list is rejected.
pub fn resolve_browse_path(raw: &str, allowed_roots: &[PathBuf]) -> Result<PathBuf, AppError> {
if raw.contains('\0') {
return Err(AppError::BadRequest("path contains null byte".into()));
}
let expanded = expand_tilde(raw.trim());
let canonical = fs::canonicalize(&expanded).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => AppError::NotFound(format!("path not found: {}", raw)),
_ => AppError::BadRequest(format!("cannot resolve path '{}': {}", raw, e)),
})?;
let allowed = allowed_roots.iter().any(|root| match fs::canonicalize(root) {
Ok(canonical_root) => canonical.starts_with(&canonical_root),
Err(_) => false,
});
if !allowed {
return Err(AppError::Forbidden(format!(
"path '{}' is outside the allowed sandbox",
raw
)));
}
Ok(canonical)
}
fn expand_tilde(input: &str) -> PathBuf {
if let Some(stripped) = input.strip_prefix('~')
&& let Some(home) = dirs::home_dir()
{
let relative = stripped.trim_start_matches(['/', '\\']);
return if relative.is_empty() { home } else { home.join(relative) };
}
PathBuf::from(input)
}
// ---------------------------------------------------------------------------
// Directory listing
// ---------------------------------------------------------------------------
/// List a single directory level.
///
/// `dir` must already be a canonicalized path that the caller has verified
/// lives under `allowed_roots`. Hidden entries (name starting with `.`) are
/// filtered out to match the legacy Express behavior.
pub fn list_directory(
dir: &Path,
show_files: bool,
allowed_roots: &[PathBuf],
) -> Result<BrowseDirectoryResponse, AppError> {
let metadata = fs::metadata(dir).map_err(|e| AppError::NotFound(format!("cannot access directory: {}", e)))?;
if !metadata.is_dir() {
return Err(AppError::BadRequest("path is not a directory".into()));
}
let read = fs::read_dir(dir).map_err(|e| AppError::Internal(format!("readdir failed: {}", e)))?;
let mut items: Vec<BrowseEntry> = Vec::new();
for entry in read.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let entry_path = entry.path();
let stat = match fs::metadata(&entry_path) {
Ok(m) => m,
Err(_) => continue, // skip unreadable entries (permission, dangling symlink, etc.)
};
let is_dir = stat.is_dir();
let is_file = stat.is_file();
if !show_files && !is_dir {
continue;
}
items.push(BrowseEntry {
name,
path: entry_path.to_string_lossy().into_owned(),
is_directory: is_dir,
is_file,
size: Some(stat.len()),
modified: system_time_to_millis(stat.modified().ok()),
});
}
items.sort_by(|a, b| match (a.is_directory, b.is_directory) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
let truncated = items.len() > MAX_BROWSE_ITEMS;
if truncated {
items.truncate(MAX_BROWSE_ITEMS);
}
let (parent_path, can_go_up) = navigation_hints(dir, allowed_roots);
Ok(BrowseDirectoryResponse {
current_path: dir.to_string_lossy().into_owned(),
parent_path,
items,
can_go_up,
truncated,
is_root: None,
})
}
fn system_time_to_millis(t: Option<SystemTime>) -> Option<i64> {
let time = t?;
let duration = time.duration_since(UNIX_EPOCH).ok()?;
duration.as_millis().try_into().ok()
}
/// Compute `(parent_path, can_go_up)` for a listed directory.
///
/// - At a Windows drive root (`C:\` whose parent is itself), returns
/// `("__ROOT__", true)` so the UI jumps back to the drive picker.
/// - When the natural parent is still inside the allow-list, returns that
/// parent with `can_go_up = true`.
/// - Otherwise, returns the parent path with `can_go_up = false`; the UI
/// hides the up-arrow but keeps the path for display.
fn navigation_hints(dir: &Path, allowed_roots: &[PathBuf]) -> (Option<String>, bool) {
let parent = match dir.parent() {
Some(p) => p,
None => return (None, false),
};
if parent == dir {
// Drive root on Windows — parent path is the drive itself.
if cfg!(windows) {
return (Some(ROOT_SENTINEL.to_owned()), true);
}
return (None, false);
}
let parent_allowed = allowed_roots.iter().any(|root| match fs::canonicalize(root) {
Ok(canonical_root) => parent.starts_with(&canonical_root),
Err(_) => false,
});
(Some(parent.to_string_lossy().into_owned()), parent_allowed)
}
// ---------------------------------------------------------------------------
// High-level entry point
// ---------------------------------------------------------------------------
/// Handler-facing entry point: apply the special-case routing (empty path,
/// `__ROOT__`) and delegate to the real lister.
pub fn browse(
raw_path: Option<&str>,
show_files: bool,
allowed_roots: &[PathBuf],
) -> Result<BrowseDirectoryResponse, AppError> {
let requested = raw_path.map(str::trim).unwrap_or("");
#[cfg(windows)]
{
if requested.is_empty() || requested == ROOT_SENTINEL {
return Ok(drive_list_response());
}
}
let target = if requested.is_empty() {
std::env::current_dir()
.map_err(|e| AppError::Internal(format!("cannot read cwd: {}", e)))?
.to_string_lossy()
.into_owned()
} else {
requested.to_owned()
};
let canonical = resolve_browse_path(&target, allowed_roots)?;
list_directory(&canonical, show_files, allowed_roots)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn roots_from(paths: &[&Path]) -> Vec<PathBuf> {
paths
.iter()
.map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()))
.collect()
}
#[test]
fn lists_directories_only_when_show_files_false() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir(tmp.path().join("sub")).unwrap();
fs::write(tmp.path().join("a.txt"), "x").unwrap();
let roots = roots_from(&[tmp.path()]);
let resp = browse(Some(tmp.path().to_str().unwrap()), false, &roots).unwrap();
assert_eq!(resp.items.len(), 1);
assert_eq!(resp.items[0].name, "sub");
}
#[test]
fn lists_files_when_show_files_true() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir(tmp.path().join("sub")).unwrap();
fs::write(tmp.path().join("a.txt"), "x").unwrap();
let roots = roots_from(&[tmp.path()]);
let resp = browse(Some(tmp.path().to_str().unwrap()), true, &roots).unwrap();
assert_eq!(resp.items.len(), 2);
// directories sort before files
assert_eq!(resp.items[0].name, "sub");
assert_eq!(resp.items[1].name, "a.txt");
assert!(resp.items[1].is_file);
}
#[test]
fn filters_hidden_entries() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".secret"), "x").unwrap();
fs::write(tmp.path().join("visible.txt"), "y").unwrap();
let roots = roots_from(&[tmp.path()]);
let resp = browse(Some(tmp.path().to_str().unwrap()), true, &roots).unwrap();
assert_eq!(resp.items.len(), 1);
assert_eq!(resp.items[0].name, "visible.txt");
}
#[test]
fn rejects_path_outside_sandbox() {
let sandbox = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let roots = roots_from(&[sandbox.path()]);
let err = browse(Some(outside.path().to_str().unwrap()), false, &roots).unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)), "expected forbidden, got {err}");
}
#[test]
fn rejects_nonexistent_path() {
let sandbox = tempfile::tempdir().unwrap();
let fake = sandbox.path().join("does-not-exist");
let roots = roots_from(&[sandbox.path()]);
let err = browse(Some(fake.to_str().unwrap()), false, &roots).unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "expected not-found, got {err}");
}
#[test]
fn rejects_null_byte() {
let sandbox = tempfile::tempdir().unwrap();
let roots = roots_from(&[sandbox.path()]);
let err = browse(Some("/tmp/\0evil"), false, &roots).unwrap_err();
assert!(matches!(err, AppError::BadRequest(_)));
}
#[test]
fn rejects_file_as_directory() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("hi.txt");
fs::write(&file, "x").unwrap();
let roots = roots_from(&[tmp.path()]);
let err = browse(Some(file.to_str().unwrap()), false, &roots).unwrap_err();
assert!(
matches!(err, AppError::BadRequest(_)),
"expected bad-request, got {err}"
);
}
#[test]
fn can_go_up_when_parent_inside_sandbox() {
let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("child");
fs::create_dir(&sub).unwrap();
let roots = roots_from(&[tmp.path()]);
let resp = browse(Some(sub.to_str().unwrap()), false, &roots).unwrap();
assert!(resp.can_go_up);
assert!(resp.parent_path.is_some());
}
#[test]
fn can_go_up_false_when_parent_outside_sandbox() {
let sandbox = tempfile::tempdir().unwrap();
let roots = roots_from(&[sandbox.path()]);
let resp = browse(Some(sandbox.path().to_str().unwrap()), false, &roots).unwrap();
// The sandbox's parent is outside the allow-list, so can_go_up must be false.
assert!(!resp.can_go_up);
}
#[test]
fn truncates_large_directories() {
let tmp = tempfile::tempdir().unwrap();
// Create MAX_BROWSE_ITEMS + 5 directories so the filter keeps them all.
for i in 0..(MAX_BROWSE_ITEMS + 5) {
fs::create_dir(tmp.path().join(format!("d{i:05}"))).unwrap();
}
let roots = roots_from(&[tmp.path()]);
let resp = browse(Some(tmp.path().to_str().unwrap()), false, &roots).unwrap();
assert_eq!(resp.items.len(), MAX_BROWSE_ITEMS);
assert!(resp.truncated);
}
#[test]
fn empty_path_defaults_to_cwd_on_unix() {
#[cfg(unix)]
{
let cwd = std::env::current_dir().unwrap();
let roots = roots_from(&[cwd.as_path()]);
let resp = browse(Some(""), false, &roots).unwrap();
assert!(!resp.is_root.unwrap_or(false));
assert_eq!(
fs::canonicalize(&resp.current_path).unwrap(),
fs::canonicalize(&cwd).unwrap()
);
}
}
#[cfg(unix)]
#[test]
fn rejects_symlink_escaping_sandbox() {
let sandbox = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let link = sandbox.path().join("escape");
std::os::unix::fs::symlink(outside.path(), &link).unwrap();
let roots = roots_from(&[sandbox.path()]);
let err = browse(Some(link.to_str().unwrap()), false, &roots).unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)), "expected forbidden, got {err}");
}
#[test]
fn tilde_expands_to_home() {
let home = dirs::home_dir().expect("home dir");
let expanded = expand_tilde("~/Documents");
assert_eq!(expanded, home.join("Documents"));
assert_eq!(expand_tilde("~"), home);
}
}
@@ -0,0 +1,24 @@
//! File system operations: read/write, path safety, file watching, snapshots, and zip.
pub mod browse;
pub mod path_safety;
pub mod routes;
pub mod service;
pub mod snapshot_service;
pub mod traits;
pub mod types;
pub mod watch_service;
pub mod workspace_listing;
pub use path_safety::{has_traversal, validate_path, validate_path_for_write};
pub use routes::{FileRouterState, file_routes};
pub use service::FileService;
pub use snapshot_service::SnapshotService;
pub use traits::{
FileServiceRef, FileWatchServiceRef, IFileService, IFileWatchService, ISnapshotService, SnapshotServiceRef,
};
pub use types::{
CompareResult, ContentUpdateEvent, ContentUpdateOperation, CopyResult, DirOrFile, FileChangeInfo, FileMetadata,
FileWatchEvent, OfficeFileAddedEvent, SnapshotInfo, SnapshotMode, WorkspaceFlatFile, ZipEntry,
};
pub use watch_service::FileWatchService;
pub use workspace_listing::{MAX_DIR_DEPTH, list_workspace_level};
@@ -0,0 +1,266 @@
use std::path::{Component, Path, PathBuf};
use nomifun_common::AppError;
/// Canonicalize `path` and verify it falls within one of the `allowed_roots`.
///
/// This prevents path traversal attacks (e.g. `../../etc/passwd`) by:
/// 1. Resolving symlinks and `..` components via `std::fs::canonicalize`.
/// 2. Checking that the resolved path starts with at least one allowed root.
///
/// # Errors
///
/// - `AppError::BadRequest` if `path` does not exist or cannot be
/// canonicalized, or if it falls outside all allowed roots.
pub fn validate_path(path: &str, allowed_roots: &[&Path]) -> Result<PathBuf, AppError> {
let canonical = std::fs::canonicalize(path)
.map_err(|e| AppError::BadRequest(format!("cannot resolve path '{}': {}", path, e)))?;
let is_allowed = allowed_roots.iter().any(|root| {
// Canonicalize the root as well so that symlinks (e.g. macOS
// /var → /private/var) are handled consistently.
match std::fs::canonicalize(root) {
Ok(canonical_root) => canonical.starts_with(&canonical_root),
Err(_) => false,
}
});
if is_allowed {
Ok(canonical)
} else {
Err(AppError::Forbidden(format!(
"path '{}' is outside the allowed sandbox",
path
)))
}
}
/// Like [`validate_path`], but also accepts a request-scoped extra root.
pub fn validate_path_with_extra_root(
path: &str,
base_roots: &[&Path],
extra: Option<&Path>,
) -> Result<PathBuf, AppError> {
let mut allowed_roots = base_roots.to_vec();
if let Some(extra_root) = extra {
allowed_roots.push(extra_root);
}
validate_path(path, &allowed_roots)
}
/// Like [`validate_path`] but the target does not need to exist yet.
///
/// Canonicalizes the *parent directory* and verifies it is within the sandbox,
/// then appends the file name component. Useful for write/create operations
/// where the file itself may not exist yet.
///
/// # Errors
///
/// Same as [`validate_path`], plus `AppError::BadRequest` if the path has
/// no parent or no file-name component.
pub fn validate_path_for_write(path: &str, allowed_roots: &[&Path]) -> Result<PathBuf, AppError> {
let p = Path::new(path);
let parent = p
.parent()
.ok_or_else(|| AppError::BadRequest(format!("path '{}' has no parent directory", path)))?;
let file_name = p
.file_name()
.ok_or_else(|| AppError::BadRequest(format!("path '{}' has no file name component", path)))?;
let canonical_parent = std::fs::canonicalize(parent)
.map_err(|e| AppError::BadRequest(format!("cannot resolve parent of '{}': {}", path, e)))?;
let is_allowed = allowed_roots.iter().any(|root| match std::fs::canonicalize(root) {
Ok(canonical_root) => canonical_parent.starts_with(&canonical_root),
Err(_) => false,
});
if !is_allowed {
return Err(AppError::Forbidden(format!(
"path '{}' is outside the allowed sandbox",
path
)));
}
Ok(canonical_parent.join(file_name))
}
/// Check whether a raw path string contains suspicious traversal patterns.
///
/// This is a fast pre-check that catches obvious `..` usage before the
/// more expensive `canonicalize` call. It does NOT replace full validation
/// — always call [`validate_path`] or [`validate_path_for_write`] as the
/// authoritative check.
pub fn has_traversal(path: &str) -> bool {
path.contains('\0')
|| Path::new(path)
.components()
.any(|component| matches!(component, Component::ParentDir))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn validate_path_within_sandbox() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hello.txt");
fs::write(&file, "hi").unwrap();
let result = validate_path(file.to_str().unwrap(), &[dir.path()]);
assert!(result.is_ok());
assert_eq!(result.unwrap(), fs::canonicalize(&file).unwrap());
}
#[test]
fn validate_path_rejects_outside_sandbox() {
let sandbox = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let file = outside.path().join("secret.txt");
fs::write(&file, "secret").unwrap();
let result = validate_path(file.to_str().unwrap(), &[sandbox.path()]);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)), "unexpected error: {err}");
}
#[test]
fn validate_path_rejects_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("does_not_exist.txt");
let result = validate_path(fake.to_str().unwrap(), &[dir.path()]);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot resolve"));
}
#[test]
fn validate_path_resolves_symlink_within_sandbox() {
let dir = tempfile::tempdir().unwrap();
let real_file = dir.path().join("real.txt");
fs::write(&real_file, "content").unwrap();
let link = dir.path().join("link.txt");
#[cfg(unix)]
std::os::unix::fs::symlink(&real_file, &link).unwrap();
#[cfg(not(unix))]
{
// Skip on non-unix
return;
}
let result = validate_path(link.to_str().unwrap(), &[dir.path()]);
assert!(result.is_ok());
}
#[test]
fn validate_path_rejects_symlink_escaping_sandbox() {
let sandbox = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let secret = outside.path().join("secret.txt");
fs::write(&secret, "secret").unwrap();
let link = sandbox.path().join("escape");
#[cfg(unix)]
std::os::unix::fs::symlink(&secret, &link).unwrap();
#[cfg(not(unix))]
{
return;
}
let result = validate_path(link.to_str().unwrap(), &[sandbox.path()]);
assert!(result.is_err());
}
#[test]
fn validate_path_for_write_new_file() {
let dir = tempfile::tempdir().unwrap();
// File does not exist yet, but parent does
let new_file = dir.path().join("new.txt");
let result = validate_path_for_write(new_file.to_str().unwrap(), &[dir.path()]);
assert!(result.is_ok());
let resolved = result.unwrap();
assert!(resolved.ends_with("new.txt"));
}
#[test]
fn validate_path_for_write_rejects_outside_sandbox() {
let sandbox = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("evil.txt");
let result = validate_path_for_write(target.to_str().unwrap(), &[sandbox.path()]);
assert!(result.is_err());
}
#[test]
fn validate_path_for_write_rejects_no_parent() {
// A bare root path on unix is "/" which has no parent in some
// interpretations, but Path::new("/").parent() returns Some("").
// Test a truly pathological case.
let result = validate_path_for_write("", &[Path::new("/tmp")]);
assert!(result.is_err());
}
#[test]
fn validate_path_multiple_allowed_roots() {
let root_a = tempfile::tempdir().unwrap();
let root_b = tempfile::tempdir().unwrap();
let file_a = root_a.path().join("a.txt");
let file_b = root_b.path().join("b.txt");
fs::write(&file_a, "a").unwrap();
fs::write(&file_b, "b").unwrap();
let roots = [root_a.path(), root_b.path()];
assert!(validate_path(file_a.to_str().unwrap(), &roots).is_ok());
assert!(validate_path(file_b.to_str().unwrap(), &roots).is_ok());
}
#[test]
fn has_traversal_detects_dot_dot() {
assert!(has_traversal("../etc/passwd"));
assert!(has_traversal("/safe/../../etc"));
assert!(has_traversal("a\0b"));
}
#[test]
fn has_traversal_clean_paths() {
assert!(!has_traversal("/home/user/project/src/main.rs"));
assert!(!has_traversal("relative/path/file.txt"));
assert!(!has_traversal(".hidden_file"));
}
#[test]
fn has_traversal_allows_legal_filename_with_dots() {
assert!(!has_traversal("foo..bar.md"));
assert!(!has_traversal("README..old"));
assert!(!has_traversal("my..file.txt"));
}
#[test]
fn has_traversal_still_rejects_parent_dir() {
assert!(has_traversal("../etc"));
assert!(has_traversal("a/../b"));
assert!(has_traversal(".."));
assert!(has_traversal("/foo/../bar"));
}
#[test]
fn validate_path_accepts_extra_workspace_root() {
let sandbox = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let file = workspace.path().join("hello.txt");
fs::write(&file, "hi").unwrap();
let result = validate_path_with_extra_root(file.to_str().unwrap(), &[sandbox.path()], Some(workspace.path()));
assert!(result.is_ok());
assert_eq!(result.unwrap(), fs::canonicalize(file).unwrap());
}
}
@@ -0,0 +1,874 @@
use axum::Router;
use axum::extract::rejection::JsonRejection;
use axum::extract::{DefaultBodyLimit, Json, Multipart, Query, State};
use axum::routing::{get, post};
use std::path::Path;
use tower_http::limit::RequestBodyLimitLayer;
use nomifun_api_types::{
ApiResponse, BrowseDirectoryQuery, BrowseDirectoryResponse, CancelZipRequest, CopyFilesRequest, CopyFilesResponse,
CreateTempFileRequest, DirOrFileResponse, FetchRemoteImageRequest, FileChangeInfoResponse, FileMetadataResponse,
FileWatchRequest, GetFileMetadataRequest, GetFilesByDirRequest, GetImageBase64Request, ListWorkspaceFilesRequest,
ReadFileBufferRequest, ReadFileRequest, RemoveEntryRequest, RenameRequest, RenameResponse, SnapshotBaselineRequest,
SnapshotCompareResponse, SnapshotDiscardRequest, SnapshotInfoResponse, SnapshotStageRequest,
SnapshotWorkspaceRequest, WorkspaceFlatFileResponse, WorkspaceOfficeWatchRequest, WriteFileRequest, ZipRequest,
};
use nomifun_common::AppError;
use nomifun_common::constants::UPLOAD_MAX_SIZE;
use crate::browse;
use crate::traits::{FileServiceRef, FileWatchServiceRef, SnapshotServiceRef};
use crate::types::{
CompareResult, CopyResult, DirOrFile, FileChangeInfo, FileMetadata, SnapshotInfo, SnapshotMode, WorkspaceFlatFile,
ZipEntry,
};
// ---------------------------------------------------------------------------
// Router state
// ---------------------------------------------------------------------------
/// Shared state for all file-related route handlers.
#[derive(Clone)]
pub struct FileRouterState {
pub file_service: FileServiceRef,
pub watch_service: FileWatchServiceRef,
pub snapshot_service: SnapshotServiceRef,
pub allowed_roots: Vec<std::path::PathBuf>,
/// Roots permitted by the shallow `/api/fs/browse` endpoint. This is
/// typically wider than `allowed_roots` (it includes `cwd`, Windows
/// drive letters, and `/` on Unix) because the WebUI host-file picker
/// legitimately needs to reach outside any single workspace.
pub browse_roots: Vec<std::path::PathBuf>,
}
// ---------------------------------------------------------------------------
// Router builder
// ---------------------------------------------------------------------------
/// Build the file router with all `/api/fs/*` routes.
///
/// All routes require authentication (applied by the caller).
pub fn file_routes(state: FileRouterState) -> Router {
// Upload route carries its own body-size limit (UPLOAD_MAX_SIZE, 30 MB).
// We first disable the global `DefaultBodyLimit` that `nomifun-app`
// installs (otherwise the `Multipart` extractor would cap the body at
// `BODY_LIMIT`), then apply `RequestBodyLimitLayer` as the sole hard
// cap. The layers are added in outer->inner order via `.layer()`.
let upload_router = Router::new()
.route("/api/fs/upload", post(upload_file))
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(UPLOAD_MAX_SIZE))
.with_state(state.clone());
Router::new()
// A. Core file operations
.route("/api/fs/browse", get(browse_directory))
.route("/api/fs/dir", post(get_files_by_dir))
.route("/api/fs/list", post(list_workspace_files))
.route("/api/fs/metadata", post(get_file_metadata))
.route("/api/fs/read", post(read_file))
.route("/api/fs/read-buffer", post(read_file_buffer))
.route("/api/fs/write", post(write_file))
.route("/api/fs/copy", post(copy_files))
.route("/api/fs/remove", post(remove_entry))
.route("/api/fs/rename", post(rename_entry))
.route("/api/fs/temp", post(create_temp_file))
.route("/api/fs/image-base64", post(get_image_base64))
.route("/api/fs/fetch-remote-image", post(fetch_remote_image))
.route("/api/fs/zip", post(create_zip))
.route("/api/fs/zip/cancel", post(cancel_zip))
// D. File watch
.route("/api/fs/watch/start", post(start_watch))
.route("/api/fs/watch/stop", post(stop_watch))
.route("/api/fs/watch/stop-all", post(stop_all_watches))
.route("/api/fs/office-watch/start", post(start_office_watch))
.route("/api/fs/office-watch/stop", post(stop_office_watch))
// E. Workspace snapshot
.route("/api/fs/snapshot/init", post(snapshot_init))
.route("/api/fs/snapshot/info", post(snapshot_info))
.route("/api/fs/snapshot/compare", post(snapshot_compare))
.route("/api/fs/snapshot/baseline", post(snapshot_baseline))
.route("/api/fs/snapshot/stage", post(snapshot_stage_file))
.route("/api/fs/snapshot/stage-all", post(snapshot_stage_all))
.route("/api/fs/snapshot/unstage", post(snapshot_unstage_file))
.route("/api/fs/snapshot/unstage-all", post(snapshot_unstage_all))
.route("/api/fs/snapshot/discard", post(snapshot_discard))
.route("/api/fs/snapshot/reset", post(snapshot_reset))
.route("/api/fs/snapshot/branches", post(snapshot_branches))
.route("/api/fs/snapshot/dispose", post(snapshot_dispose))
.with_state(state)
.merge(upload_router)
}
// ---------------------------------------------------------------------------
// A. Core file operations — handlers
// ---------------------------------------------------------------------------
/// `GET /api/fs/browse` — shallow directory listing for the WebUI host-file
/// picker. Runs on the Tokio blocking pool because it does synchronous
/// filesystem I/O.
async fn browse_directory(
State(state): State<FileRouterState>,
Query(query): Query<BrowseDirectoryQuery>,
) -> Result<Json<ApiResponse<BrowseDirectoryResponse>>, AppError> {
let show_files = matches!(query.show_files.as_deref(), Some("true") | Some("1"));
let raw_path = query.path.clone();
let roots = state.browse_roots.clone();
let response = tokio::task::spawn_blocking(move || browse::browse(raw_path.as_deref(), show_files, &roots))
.await
.map_err(|e| AppError::Internal(format!("browse task failed: {}", e)))??;
Ok(Json(ApiResponse::ok(response)))
}
async fn get_files_by_dir(
State(state): State<FileRouterState>,
body: Result<Json<GetFilesByDirRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Vec<DirOrFileResponse>>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let items = state.file_service.get_files_by_dir(&req.dir, &req.root).await?;
let response: Vec<DirOrFileResponse> = items.into_iter().map(to_dir_or_file_response).collect();
Ok(Json(ApiResponse::ok(response)))
}
async fn list_workspace_files(
State(state): State<FileRouterState>,
body: Result<Json<ListWorkspaceFilesRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Vec<WorkspaceFlatFileResponse>>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let items = state.file_service.list_workspace_files(&req.root).await?;
let response: Vec<WorkspaceFlatFileResponse> = items.into_iter().map(to_flat_file_response).collect();
Ok(Json(ApiResponse::ok(response)))
}
async fn get_file_metadata(
State(state): State<FileRouterState>,
body: Result<Json<GetFileMetadataRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<FileMetadataResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let meta = state
.file_service
.get_file_metadata(&req.path, req.workspace.as_deref().map(Path::new))
.await?;
Ok(Json(ApiResponse::ok(to_metadata_response(meta))))
}
async fn read_file(
State(state): State<FileRouterState>,
body: Result<Json<ReadFileRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Option<String>>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let content = state
.file_service
.read_file(&req.path, req.workspace.as_deref().map(Path::new))
.await?;
Ok(Json(ApiResponse::ok(content)))
}
async fn read_file_buffer(
State(state): State<FileRouterState>,
body: Result<Json<ReadFileBufferRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Option<String>>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let data = state
.file_service
.read_file_buffer(&req.path, req.workspace.as_deref().map(Path::new))
.await?;
// Binary data is base64-encoded for JSON transport.
let encoded = data.map(|bytes| {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
});
Ok(Json(ApiResponse::ok(encoded)))
}
async fn write_file(
State(state): State<FileRouterState>,
body: Result<Json<WriteFileRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<bool>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let workspace = req.workspace.unwrap_or_else(|| {
std::path::Path::new(&req.path)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
});
let ok = state
.file_service
.write_file(&req.path, req.data.as_bytes(), &workspace)
.await?;
Ok(Json(ApiResponse::ok(ok)))
}
async fn copy_files(
State(state): State<FileRouterState>,
body: Result<Json<CopyFilesRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<CopyFilesResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let result = state
.file_service
.copy_files_to_workspace(&req.file_paths, &req.workspace, req.source_root.as_deref())
.await?;
Ok(Json(ApiResponse::ok(to_copy_response(result))))
}
async fn remove_entry(
State(state): State<FileRouterState>,
body: Result<Json<RemoveEntryRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let workspace = req.workspace.unwrap_or_else(|| {
std::path::Path::new(&req.path)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
});
state.file_service.remove_entry(&req.path, &workspace).await?;
Ok(Json(ApiResponse::success()))
}
async fn rename_entry(
State(state): State<FileRouterState>,
body: Result<Json<RenameRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<RenameResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let new_path = state.file_service.rename_entry(&req.path, &req.new_name).await?;
Ok(Json(ApiResponse::ok(RenameResponse { new_path })))
}
async fn create_temp_file(
State(state): State<FileRouterState>,
body: Result<Json<CreateTempFileRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<String>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let path = state.file_service.create_temp_file(&req.file_name).await?;
Ok(Json(ApiResponse::ok(path)))
}
/// Fields extracted from a `/api/fs/upload` multipart request.
struct UploadMultipartFields {
file_data: Vec<u8>,
file_name: Option<String>,
dispo_file_name: Option<String>,
conversation_id: Option<String>,
}
/// Strip any directory component from a file name and reject empty results.
/// The returned name is guaranteed not to contain path separators; deeper
/// traversal validation happens in [`IFileService::create_upload_file`].
fn sanitize_upload_filename(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let last = trimmed.rsplit(['/', '\\']).next().unwrap_or("");
let last = last.trim();
if last.is_empty() { None } else { Some(last.to_owned()) }
}
async fn extract_upload_multipart(mut multipart: Multipart) -> Result<UploadMultipartFields, AppError> {
let mut file_data: Option<Vec<u8>> = None;
let mut file_name: Option<String> = None;
let mut dispo_file_name: Option<String> = None;
let mut conversation_id: Option<String> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(format!("multipart error: {e}")))?
{
let name = field.name().unwrap_or("").to_owned();
match name.as_str() {
"file" => {
// Capture the Content-Disposition filename (if any) before
// consuming the field body — `field.file_name()` is only
// available on the field metadata, not on the Bytes below.
dispo_file_name = field.file_name().and_then(sanitize_upload_filename);
file_data = Some(
field
.bytes()
.await
.map_err(|e| AppError::BadRequest(format!("failed to read file: {e}")))?
.to_vec(),
);
}
"file_name" => {
let text = field
.text()
.await
.map_err(|e| AppError::BadRequest(format!("failed to read file_name: {e}")))?;
if let Some(name) = sanitize_upload_filename(&text) {
file_name = Some(name);
}
}
"conversation_id" => {
let text = field
.text()
.await
.map_err(|e| AppError::BadRequest(format!("failed to read conversation_id: {e}")))?;
let trimmed = text.trim();
if !trimmed.is_empty() {
conversation_id = Some(trimmed.to_owned());
}
}
_ => {}
}
}
let file_data = file_data.ok_or_else(|| AppError::BadRequest("missing 'file' field".to_owned()))?;
Ok(UploadMultipartFields {
file_data,
file_name,
dispo_file_name,
conversation_id,
})
}
async fn upload_file(
State(state): State<FileRouterState>,
multipart: Multipart,
) -> Result<Json<ApiResponse<String>>, AppError> {
let fields = extract_upload_multipart(multipart).await?;
let file_name = fields.file_name.or(fields.dispo_file_name).ok_or_else(|| {
AppError::BadRequest("missing file name: provide 'file_name' or a multipart filename".to_owned())
})?;
let path = state
.file_service
.create_upload_file(&file_name, &fields.file_data, fields.conversation_id.as_deref())
.await?;
Ok(Json(ApiResponse::ok(path)))
}
async fn get_image_base64(
State(state): State<FileRouterState>,
body: Result<Json<GetImageBase64Request>, JsonRejection>,
) -> Result<Json<ApiResponse<String>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let data_url = state
.file_service
.get_image_base64(&req.path, req.workspace.as_deref().map(Path::new))
.await?;
Ok(Json(ApiResponse::ok(data_url)))
}
async fn fetch_remote_image(
State(state): State<FileRouterState>,
body: Result<Json<FetchRemoteImageRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<String>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let data_url = state.file_service.fetch_remote_image(&req.url).await;
Ok(Json(ApiResponse::ok(data_url)))
}
async fn create_zip(
State(state): State<FileRouterState>,
body: Result<Json<ZipRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<bool>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let entries: Vec<ZipEntry> = req.files.into_iter().map(to_zip_entry).collect();
let ok = state
.file_service
.create_zip(&req.path, entries, req.request_id)
.await?;
Ok(Json(ApiResponse::ok(ok)))
}
async fn cancel_zip(
State(state): State<FileRouterState>,
body: Result<Json<CancelZipRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<bool>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let ok = state.file_service.cancel_zip(&req.request_id).await;
Ok(Json(ApiResponse::ok(ok)))
}
// ---------------------------------------------------------------------------
// D. File watch — handlers
// ---------------------------------------------------------------------------
async fn start_watch(
State(state): State<FileRouterState>,
body: Result<Json<FileWatchRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.watch_service.start_watch(&req.file_path).await?;
Ok(Json(ApiResponse::success()))
}
async fn stop_watch(
State(state): State<FileRouterState>,
body: Result<Json<FileWatchRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.watch_service.stop_watch(&req.file_path).await?;
Ok(Json(ApiResponse::success()))
}
async fn stop_all_watches(State(state): State<FileRouterState>) -> Result<Json<ApiResponse<()>>, AppError> {
state.watch_service.stop_all_watches().await?;
Ok(Json(ApiResponse::success()))
}
async fn start_office_watch(
State(state): State<FileRouterState>,
body: Result<Json<WorkspaceOfficeWatchRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let allowed_roots: Vec<&Path> = state.allowed_roots.iter().map(std::path::PathBuf::as_path).collect();
crate::path_safety::validate_path_with_extra_root(&req.workspace, &allowed_roots, Some(Path::new(&req.workspace)))?;
state.watch_service.start_office_watch(&req.workspace).await?;
Ok(Json(ApiResponse::success()))
}
async fn stop_office_watch(
State(state): State<FileRouterState>,
body: Result<Json<WorkspaceOfficeWatchRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.watch_service.stop_office_watch(&req.workspace).await?;
Ok(Json(ApiResponse::success()))
}
// ---------------------------------------------------------------------------
// E. Workspace snapshot — handlers
// ---------------------------------------------------------------------------
async fn snapshot_init(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotWorkspaceRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<SnapshotInfoResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let info = state.snapshot_service.init(&req.workspace).await?;
Ok(Json(ApiResponse::ok(to_snapshot_info_response(info))))
}
async fn snapshot_info(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotWorkspaceRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<SnapshotInfoResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let info = state.snapshot_service.get_info(&req.workspace).await?;
Ok(Json(ApiResponse::ok(to_snapshot_info_response(info))))
}
async fn snapshot_compare(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotWorkspaceRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<SnapshotCompareResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let result = state.snapshot_service.compare(&req.workspace).await?;
Ok(Json(ApiResponse::ok(to_compare_response(result))))
}
async fn snapshot_baseline(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotBaselineRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Option<String>>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let content = state
.snapshot_service
.get_baseline_content(&req.workspace, &req.file_path)
.await?;
Ok(Json(ApiResponse::ok(content)))
}
async fn snapshot_stage_file(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotStageRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.snapshot_service
.stage_file(&req.workspace, &req.file_path)
.await?;
Ok(Json(ApiResponse::success()))
}
async fn snapshot_stage_all(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotWorkspaceRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.snapshot_service.stage_all(&req.workspace).await?;
Ok(Json(ApiResponse::success()))
}
async fn snapshot_unstage_file(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotStageRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.snapshot_service
.unstage_file(&req.workspace, &req.file_path)
.await?;
Ok(Json(ApiResponse::success()))
}
async fn snapshot_unstage_all(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotWorkspaceRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.snapshot_service.unstage_all(&req.workspace).await?;
Ok(Json(ApiResponse::success()))
}
async fn snapshot_discard(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotDiscardRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.snapshot_service
.discard_file(&req.workspace, &req.file_path, req.operation)
.await?;
Ok(Json(ApiResponse::success()))
}
async fn snapshot_reset(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotDiscardRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.snapshot_service
.reset_file(&req.workspace, &req.file_path, req.operation)
.await?;
Ok(Json(ApiResponse::success()))
}
async fn snapshot_branches(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotWorkspaceRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Vec<String>>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let branches = state.snapshot_service.get_branches(&req.workspace).await?;
Ok(Json(ApiResponse::ok(branches)))
}
async fn snapshot_dispose(
State(state): State<FileRouterState>,
body: Result<Json<SnapshotWorkspaceRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.snapshot_service.dispose(&req.workspace).await?;
Ok(Json(ApiResponse::success()))
}
// ---------------------------------------------------------------------------
// Domain → DTO conversions
// ---------------------------------------------------------------------------
fn to_dir_or_file_response(d: DirOrFile) -> DirOrFileResponse {
let children = if d.is_dir {
Some(d.children.into_iter().map(to_dir_or_file_response).collect())
} else {
None
};
DirOrFileResponse {
name: d.name,
full_path: d.full_path,
relative_path: d.relative_path,
is_dir: d.is_dir,
is_file: !d.is_dir,
children,
}
}
fn to_flat_file_response(f: WorkspaceFlatFile) -> WorkspaceFlatFileResponse {
WorkspaceFlatFileResponse {
name: f.name,
full_path: f.full_path,
relative_path: f.relative_path,
}
}
fn to_metadata_response(m: FileMetadata) -> FileMetadataResponse {
FileMetadataResponse {
name: m.name,
path: m.path,
size: m.size,
mime_type: m.mime_type,
last_modified: m.last_modified,
is_directory: if m.is_directory { Some(true) } else { None },
}
}
fn to_copy_response(r: CopyResult) -> CopyFilesResponse {
CopyFilesResponse {
copied_files: r.copied_files,
failed_files: r.failed_files,
}
}
fn to_zip_entry(e: nomifun_api_types::ZipFileEntry) -> ZipEntry {
if let Some(content) = e.content {
ZipEntry::Text { name: e.name, content }
} else if let Some(file_path) = e.file_path {
ZipEntry::Disk {
name: e.name,
file_path,
}
} else {
// Fallback: treat as empty text entry
ZipEntry::Text {
name: e.name,
content: String::new(),
}
}
}
fn to_snapshot_info_response(info: SnapshotInfo) -> SnapshotInfoResponse {
let (mode, reason) = match info.mode {
SnapshotMode::GitRepo => (nomifun_api_types::SnapshotMode::GitRepo, None),
SnapshotMode::Snapshot => (nomifun_api_types::SnapshotMode::Snapshot, None),
SnapshotMode::Disabled { reason } => (nomifun_api_types::SnapshotMode::Disabled, Some(reason)),
};
SnapshotInfoResponse {
mode,
branch: info.branch,
reason,
}
}
fn to_file_change_response(c: FileChangeInfo) -> FileChangeInfoResponse {
FileChangeInfoResponse {
file_path: c.file_path,
relative_path: c.relative_path,
operation: c.operation,
}
}
fn to_compare_response(r: CompareResult) -> SnapshotCompareResponse {
SnapshotCompareResponse {
staged: r.staged.into_iter().map(to_file_change_response).collect(),
unstaged: r.unstaged.into_iter().map(to_file_change_response).collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dir_or_file_response_conversion_file() {
let d = DirOrFile {
name: "test.txt".into(),
full_path: "/ws/test.txt".into(),
relative_path: "test.txt".into(),
is_dir: false,
children: vec![],
};
let r = to_dir_or_file_response(d);
assert_eq!(r.name, "test.txt");
assert!(!r.is_dir);
assert!(r.is_file);
assert!(r.children.is_none());
}
#[test]
fn dir_or_file_response_conversion_dir_with_children() {
let d = DirOrFile {
name: "src".into(),
full_path: "/ws/src".into(),
relative_path: "src".into(),
is_dir: true,
children: vec![DirOrFile {
name: "main.rs".into(),
full_path: "/ws/src/main.rs".into(),
relative_path: "src/main.rs".into(),
is_dir: false,
children: vec![],
}],
};
let r = to_dir_or_file_response(d);
assert!(r.is_dir);
assert!(!r.is_file);
let children = r.children.unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0].name, "main.rs");
}
#[test]
fn flat_file_response_conversion() {
let f = WorkspaceFlatFile {
name: "lib.rs".into(),
full_path: "/ws/src/lib.rs".into(),
relative_path: "src/lib.rs".into(),
};
let r = to_flat_file_response(f);
assert_eq!(r.name, "lib.rs");
assert_eq!(r.full_path, "/ws/src/lib.rs");
assert_eq!(r.relative_path, "src/lib.rs");
}
#[test]
fn metadata_response_conversion_file() {
let m = FileMetadata {
name: "readme.md".into(),
path: "/ws/readme.md".into(),
size: 1024,
mime_type: "text/markdown".into(),
last_modified: 1700000000000,
is_directory: false,
};
let r = to_metadata_response(m);
assert_eq!(r.name, "readme.md");
assert_eq!(r.size, 1024);
assert!(r.is_directory.is_none());
}
#[test]
fn metadata_response_conversion_directory() {
let m = FileMetadata {
name: "src".into(),
path: "/ws/src".into(),
size: 0,
mime_type: "".into(),
last_modified: 1700000000000,
is_directory: true,
};
let r = to_metadata_response(m);
assert_eq!(r.is_directory, Some(true));
}
#[test]
fn zip_entry_conversion_text() {
let e = nomifun_api_types::ZipFileEntry {
name: "a.txt".into(),
content: Some("hello".into()),
file_path: None,
};
let z = to_zip_entry(e);
match z {
ZipEntry::Text { name, content } => {
assert_eq!(name, "a.txt");
assert_eq!(content, "hello");
}
_ => panic!("expected Text variant"),
}
}
#[test]
fn zip_entry_conversion_disk() {
let e = nomifun_api_types::ZipFileEntry {
name: "b.bin".into(),
content: None,
file_path: Some("/src/b.bin".into()),
};
let z = to_zip_entry(e);
match z {
ZipEntry::Disk { name, file_path } => {
assert_eq!(name, "b.bin");
assert_eq!(file_path, "/src/b.bin");
}
_ => panic!("expected Disk variant"),
}
}
#[test]
fn zip_entry_conversion_empty_fallback() {
let e = nomifun_api_types::ZipFileEntry {
name: "empty.txt".into(),
content: None,
file_path: None,
};
let z = to_zip_entry(e);
match z {
ZipEntry::Text { name, content } => {
assert_eq!(name, "empty.txt");
assert!(content.is_empty());
}
_ => panic!("expected Text variant"),
}
}
#[test]
fn snapshot_info_response_git_repo() {
let info = SnapshotInfo {
mode: SnapshotMode::GitRepo,
branch: Some("main".into()),
};
let r = to_snapshot_info_response(info);
assert_eq!(r.mode, nomifun_api_types::SnapshotMode::GitRepo);
assert_eq!(r.branch, Some("main".into()));
}
#[test]
fn snapshot_info_response_snapshot_mode() {
let info = SnapshotInfo {
mode: SnapshotMode::Snapshot,
branch: None,
};
let r = to_snapshot_info_response(info);
assert_eq!(r.mode, nomifun_api_types::SnapshotMode::Snapshot);
assert!(r.branch.is_none());
}
#[test]
fn snapshot_info_response_disabled_mode_carries_reason() {
let info = SnapshotInfo {
mode: SnapshotMode::Disabled {
reason: "drive root".into(),
},
branch: None,
};
let r = to_snapshot_info_response(info);
assert_eq!(r.mode, nomifun_api_types::SnapshotMode::Disabled);
assert!(r.branch.is_none());
assert_eq!(r.reason.as_deref(), Some("drive root"));
}
#[test]
fn compare_response_conversion() {
use nomifun_common::FileChangeOperation;
let result = CompareResult {
staged: vec![FileChangeInfo {
file_path: "/ws/a.txt".into(),
relative_path: "a.txt".into(),
operation: FileChangeOperation::Create,
}],
unstaged: vec![FileChangeInfo {
file_path: "/ws/b.txt".into(),
relative_path: "b.txt".into(),
operation: FileChangeOperation::Modify,
}],
};
let r = to_compare_response(result);
assert_eq!(r.staged.len(), 1);
assert_eq!(r.staged[0].file_path, "/ws/a.txt");
assert_eq!(r.staged[0].operation, FileChangeOperation::Create);
assert_eq!(r.unstaged.len(), 1);
assert_eq!(r.unstaged[0].operation, FileChangeOperation::Modify);
}
// ---- sanitize_upload_filename -----------------------------------------
#[test]
fn sanitize_upload_filename_strips_directory_components() {
assert_eq!(sanitize_upload_filename("a/b/c.png").as_deref(), Some("c.png"));
assert_eq!(sanitize_upload_filename("C:\\tmp\\d.jpg").as_deref(), Some("d.jpg"));
assert_eq!(
sanitize_upload_filename(" spaced.txt ").as_deref(),
Some("spaced.txt")
);
}
#[test]
fn sanitize_upload_filename_rejects_empty() {
assert_eq!(sanitize_upload_filename(""), None);
assert_eq!(sanitize_upload_filename(" "), None);
assert_eq!(sanitize_upload_filename("/"), None);
assert_eq!(sanitize_upload_filename("a/b/"), None);
}
#[test]
fn sanitize_upload_filename_plain_passthrough() {
assert_eq!(sanitize_upload_filename("image.png").as_deref(), Some("image.png"));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
//! Git-based workspace snapshot service.
//!
//! Supports two modes:
//! - **git-repo**: directory already has `.git` — uses it directly.
//! - **snapshot**: no `.git` — creates a temporary git repo that tracks the
//! workspace via a separate worktree.
mod helpers;
use dashmap::DashMap;
use git2::Repository;
use nomifun_common::{AppError, FileChangeOperation};
use crate::types::{CompareResult, SnapshotInfo, SnapshotMode};
use helpers::{
SNAPSHOT_DIR_PREFIX, WorkspaceState, build_info, discard_single_file, init_snapshot_repo, list_branches, open_repo,
parse_statuses, read_baseline, reset_single_file, resolve_workspace, snapshot_guard, stage_all_with_deletions,
stage_single_file, temp_repo_path, unstage_all_files, unstage_single_file,
};
// ---------------------------------------------------------------------------
// SnapshotService
// ---------------------------------------------------------------------------
/// Git-based workspace snapshot service.
pub struct SnapshotService {
workspaces: DashMap<String, WorkspaceState>,
}
impl Default for SnapshotService {
fn default() -> Self {
Self::new()
}
}
impl SnapshotService {
pub fn new() -> Self {
Self {
workspaces: DashMap::new(),
}
}
/// Number of currently-tracked workspaces. Test/observability helper.
#[doc(hidden)]
pub fn workspace_count(&self) -> usize {
self.workspaces.len()
}
/// Whether the workspace string (after canonicalization) is currently
/// tracked. Test/observability helper.
#[doc(hidden)]
pub fn is_tracked(&self, workspace: &str) -> bool {
self.workspaces.contains_key(&workspace_key(workspace))
}
/// The git/temp repo path backing a tracked workspace, if any.
/// Test/observability helper.
#[doc(hidden)]
pub fn repo_path_for(&self, workspace: &str) -> Option<std::path::PathBuf> {
self.workspaces.get(&workspace_key(workspace)).map(|s| s.repo_path.clone())
}
/// Remove leftover `nomifun-snapshot-*` directories from the system temp
/// dir. Call once at application startup.
pub fn cleanup_stale_snapshots() {
let temp_dir = std::env::temp_dir();
let entries = match std::fs::read_dir(&temp_dir) {
Ok(e) => e,
Err(e) => {
tracing::warn!(
error = %e,
"Failed to read temp dir for snapshot cleanup"
);
return;
}
};
for entry in entries.flatten() {
let name = match entry.file_name().into_string() {
Ok(n) => n,
Err(_) => continue,
};
if name.starts_with(SNAPSHOT_DIR_PREFIX) {
let path = entry.path();
if let Err(e) = std::fs::remove_dir_all(&path) {
tracing::warn!(
path = %path.display(),
error = %e,
"Failed to clean up stale snapshot directory"
);
} else {
tracing::info!(
path = %path.display(),
"Cleaned up stale snapshot directory"
);
}
}
}
}
}
// ---------------------------------------------------------------------------
// Helper: get workspace state or return error
// ---------------------------------------------------------------------------
/// Resolve the DashMap key for a workspace string. Falls back to the raw
/// string when the path can no longer be canonicalized (e.g. the directory
/// was removed) so a still-tracked entry remains reachable for dispose.
fn workspace_key(workspace: &str) -> String {
match resolve_workspace(workspace) {
Ok(canonical) => canonical.to_string_lossy().to_string(),
Err(_) => workspace.to_owned(),
}
}
fn get_state(workspaces: &DashMap<String, WorkspaceState>, workspace: &str) -> Result<WorkspaceState, AppError> {
let key = workspace_key(workspace);
workspaces
.get(&key)
.map(|r| r.clone())
.ok_or_else(|| AppError::BadRequest(format!("Workspace not initialized: {}", workspace)))
}
// ---------------------------------------------------------------------------
// ISnapshotService implementation
// ---------------------------------------------------------------------------
#[async_trait::async_trait]
impl crate::traits::ISnapshotService for SnapshotService {
async fn init(&self, workspace: &str) -> Result<SnapshotInfo, AppError> {
// Canonicalize up front so the DashMap key is the canonical path
// string. Two raw forms that resolve to the same directory (trailing
// separator, case differences on Windows, `.`/`..` segments) collapse
// to a single entry.
let canonical = {
let ws = workspace.to_owned();
tokio::task::spawn_blocking(move || resolve_workspace(&ws))
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))??
};
let key = canonical.to_string_lossy().to_string();
// Check if already initialized (keyed by canonical path)
if let Some(mut entry) = self.workspaces.get_mut(&key) {
entry.refcount += 1;
let st = entry.clone();
drop(entry);
return tokio::task::spawn_blocking(move || {
let repo = open_repo(&st)?;
Ok(build_info(st.mode, &repo))
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?;
}
let result = tokio::task::spawn_blocking(move || {
let canonical_str = canonical.to_string_lossy().to_string();
let git_dir = canonical.join(".git");
if git_dir.exists() {
// GitRepo mode is cheap and safe -- never consult the guard.
let mode = SnapshotMode::GitRepo;
let repo_path = canonical.clone();
let state = WorkspaceState {
mode: mode.clone(),
repo_path: repo_path.clone(),
workspace_path: canonical,
refcount: 1,
};
let repo = Repository::open(&repo_path)
.map_err(|e| AppError::Internal(format!("Failed to open repo after init: {}", e)))?;
let info = build_info(mode, &repo);
return Ok::<(Option<WorkspaceState>, SnapshotInfo), AppError>((Some(state), info));
}
// Snapshot branch: run the safety guard BEFORE creating any temp
// repo. On refusal, return a Disabled info and track nothing.
if let Some(reason) = snapshot_guard(&canonical) {
let info = SnapshotInfo {
mode: SnapshotMode::Disabled { reason },
branch: None,
};
return Ok((None, info));
}
let temp = temp_repo_path(&canonical_str);
init_snapshot_repo(&canonical, &temp)?;
let mode = SnapshotMode::Snapshot;
let state = WorkspaceState {
mode: mode.clone(),
repo_path: temp.clone(),
workspace_path: canonical,
refcount: 1,
};
let repo = Repository::open(&temp)
.map_err(|e| AppError::Internal(format!("Failed to open repo after init: {}", e)))?;
let info = build_info(mode, &repo);
Ok::<(Option<WorkspaceState>, SnapshotInfo), AppError>((Some(state), info))
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))??;
let (maybe_state, info) = result;
// Disabled workspaces are not tracked: there is no repo to operate on,
// and the client reads the disabled state directly from this response.
let state = match maybe_state {
Some(s) => s,
None => return Ok(info),
};
// If a concurrent init won the race and inserted an entry while this
// task was building, fold into it (bump refcount) rather than clobber.
match self.workspaces.entry(key) {
dashmap::mapref::entry::Entry::Occupied(mut e) => {
e.get_mut().refcount += 1;
}
dashmap::mapref::entry::Entry::Vacant(e) => {
e.insert(state);
}
}
Ok(info)
}
async fn get_info(&self, workspace: &str) -> Result<SnapshotInfo, AppError> {
let state = get_state(&self.workspaces, workspace)?;
tokio::task::spawn_blocking(move || {
let repo = open_repo(&state)?;
Ok(build_info(state.mode, &repo))
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?
}
async fn compare(&self, workspace: &str) -> Result<CompareResult, AppError> {
let state = get_state(&self.workspaces, workspace)?;
tokio::task::spawn_blocking(move || {
let repo = open_repo(&state)?;
parse_statuses(&repo, &state.workspace_path)
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?
}
async fn get_baseline_content(&self, workspace: &str, file_path: &str) -> Result<Option<String>, AppError> {
let state = get_state(&self.workspaces, workspace)?;
let rel = file_path.to_owned();
tokio::task::spawn_blocking(move || {
let repo = open_repo(&state)?;
read_baseline(&repo, &rel)
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?
}
async fn stage_file(&self, workspace: &str, file_path: &str) -> Result<(), AppError> {
let state = get_state(&self.workspaces, workspace)?;
let fp = file_path.to_owned();
tokio::task::spawn_blocking(move || {
let repo = open_repo(&state)?;
stage_single_file(&repo, &fp)
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?
}
async fn stage_all(&self, workspace: &str) -> Result<(), AppError> {
let state = get_state(&self.workspaces, workspace)?;
tokio::task::spawn_blocking(move || {
let repo = open_repo(&state)?;
stage_all_with_deletions(&repo)
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?
}
async fn unstage_file(&self, workspace: &str, file_path: &str) -> Result<(), AppError> {
let state = get_state(&self.workspaces, workspace)?;
let fp = file_path.to_owned();
tokio::task::spawn_blocking(move || {
let repo = open_repo(&state)?;
unstage_single_file(&repo, &fp)
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?
}
async fn unstage_all(&self, workspace: &str) -> Result<(), AppError> {
let state = get_state(&self.workspaces, workspace)?;
tokio::task::spawn_blocking(move || {
let repo = open_repo(&state)?;
unstage_all_files(&repo)
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?
}
async fn discard_file(
&self,
workspace: &str,
file_path: &str,
operation: FileChangeOperation,
) -> Result<(), AppError> {
let state = get_state(&self.workspaces, workspace)?;
let fp = file_path.to_owned();
tokio::task::spawn_blocking(move || {
let repo = open_repo(&state)?;
discard_single_file(&repo, &state.workspace_path, &fp, operation)
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?
}
async fn reset_file(
&self,
workspace: &str,
file_path: &str,
operation: FileChangeOperation,
) -> Result<(), AppError> {
let state = get_state(&self.workspaces, workspace)?;
let fp = file_path.to_owned();
tokio::task::spawn_blocking(move || {
let repo = open_repo(&state)?;
reset_single_file(&repo, &state.workspace_path, &fp, operation)
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?
}
async fn get_branches(&self, workspace: &str) -> Result<Vec<String>, AppError> {
let state = get_state(&self.workspaces, workspace)?;
tokio::task::spawn_blocking(move || {
let repo = open_repo(&state)?;
list_branches(&repo)
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?
}
async fn dispose(&self, workspace: &str) -> Result<(), AppError> {
let key = workspace_key(workspace);
// Decrement the refcount under the shard lock. Only the call that
// drops it to 0 proceeds to actually remove the entry and clean up.
// `remove_if` holds the lock across the predicate, so the decrement and
// the remove decision are atomic w.r.t. a concurrent `init` bump.
let removed = self.workspaces.remove_if_mut(&key, |_, state| {
state.refcount = state.refcount.saturating_sub(1);
state.refcount == 0
});
let state = match removed {
// refcount hit 0 -> entry removed, proceed to clean up.
Some((_, s)) => s,
// Either not tracked (idempotent) or refcount still > 0 -> keep it.
None => return Ok(()),
};
if state.mode == SnapshotMode::Snapshot {
let repo_path = state.repo_path.clone();
tokio::task::spawn_blocking(move || {
if repo_path.exists() {
std::fs::remove_dir_all(&repo_path).map_err(|e| {
AppError::Internal(format!("Failed to remove snapshot dir {}: {}", repo_path.display(), e))
})?;
}
Ok(())
})
.await
.map_err(|e| AppError::Internal(format!("Blocking task failed: {}", e)))?
} else {
// git-repo mode: nothing to clean up
Ok(())
}
}
}
@@ -0,0 +1,194 @@
use std::path::Path;
use std::sync::Arc;
use nomifun_common::{AppError, FileChangeOperation};
use crate::types::{CompareResult, CopyResult, DirOrFile, FileMetadata, SnapshotInfo, WorkspaceFlatFile, ZipEntry};
/// Core file operations: directory browsing, file read/write, management,
/// image processing, and ZIP packaging.
///
/// All path parameters MUST be validated against the sandbox rules (see
/// `path_safety` module) before reaching this trait's implementations.
#[async_trait::async_trait]
pub trait IFileService: Send + Sync {
// -- Directory browsing --
/// List the immediate children of `dir`, returning a tree with one level
/// of depth. `root` is the workspace root used to compute relative paths.
async fn get_files_by_dir(&self, dir: &str, root: &str) -> Result<Vec<DirOrFile>, AppError>;
/// Recursively list all files under `root` as a flat list.
/// Returns at most 20,000 entries.
async fn list_workspace_files(&self, root: &str) -> Result<Vec<WorkspaceFlatFile>, AppError>;
/// Get metadata for a single file or directory.
async fn get_file_metadata(&self, path: &str, extra_root: Option<&Path>) -> Result<FileMetadata, AppError>;
// -- File read/write --
/// Read a file as UTF-8 text. Returns `None` if the file does not exist.
/// Files larger than 256 MB are rejected.
async fn read_file(&self, path: &str, extra_root: Option<&Path>) -> Result<Option<String>, AppError>;
/// Read a file as raw bytes. Returns `None` if the file does not exist.
/// Files larger than 256 MB are rejected.
async fn read_file_buffer(&self, path: &str, extra_root: Option<&Path>) -> Result<Option<Vec<u8>>, AppError>;
/// Write `data` to `path`. On success, emits a
/// `fileStream.contentUpdate` event with `operation = write`.
async fn write_file(&self, path: &str, data: &[u8], workspace: &str) -> Result<bool, AppError>;
// -- File management --
/// Copy files into `workspace`, preserving directory structure relative to
/// `source_root`. Returns lists of copied and failed paths.
async fn copy_files_to_workspace(
&self,
file_paths: &[String],
workspace: &str,
source_root: Option<&str>,
) -> Result<CopyResult, AppError>;
/// Remove a file or directory (recursively). On success, emits a
/// `fileStream.contentUpdate` event with `operation = delete`.
async fn remove_entry(&self, path: &str, workspace: &str) -> Result<(), AppError>;
/// Rename a file or directory. Returns the new absolute path.
async fn rename_entry(&self, path: &str, new_name: &str) -> Result<String, AppError>;
/// Create an empty temporary file and return its absolute path.
async fn create_temp_file(&self, file_name: &str) -> Result<String, AppError>;
/// Write `data` to a temporary file and return its absolute path.
///
/// When `conversation_id` is provided, the file is placed under a
/// per-conversation sub-directory (`<tmp>/nomifun/<conversation_id>/`);
/// otherwise the shared `<tmp>/nomifun/` directory is used (same as
/// [`create_temp_file`](Self::create_temp_file)).
///
/// `file_name` must not contain path separators or traversal patterns.
async fn create_upload_file(
&self,
file_name: &str,
data: &[u8],
conversation_id: Option<&str>,
) -> Result<String, AppError>;
// -- Image processing --
/// Read a local image and return a base64 Data URL
/// (e.g. `data:image/png;base64,...`).
async fn get_image_base64(&self, path: &str, extra_root: Option<&Path>) -> Result<String, AppError>;
/// Download a remote image and return a base64 Data URL.
/// On failure, returns a placeholder SVG Data URL.
async fn fetch_remote_image(&self, url: &str) -> String;
// -- ZIP --
/// Create a ZIP archive at `path` from `entries`.
/// If `request_id` is provided, the operation can be cancelled via
/// [`cancel_zip`](Self::cancel_zip).
async fn create_zip(
&self,
path: &str,
entries: Vec<ZipEntry>,
request_id: Option<String>,
) -> Result<bool, AppError>;
/// Cancel an in-progress ZIP operation by its `request_id`.
/// Returns `true` if a matching operation was found and cancelled.
async fn cancel_zip(&self, request_id: &str) -> bool;
}
/// File system watching: single-file changes and workspace Office file
/// additions.
#[async_trait::async_trait]
pub trait IFileWatchService: Send + Sync {
/// Start watching a single file for changes.
/// Emits `fileWatch.fileChanged` events on the broadcast channel.
async fn start_watch(&self, file_path: &str) -> Result<(), AppError>;
/// Stop watching a previously registered file.
async fn stop_watch(&self, file_path: &str) -> Result<(), AppError>;
/// Stop all active file watches.
async fn stop_all_watches(&self) -> Result<(), AppError>;
/// Start watching a workspace directory for new Office files
/// (.pptx, .docx, .xlsx).
/// Emits `workspaceOfficeWatch.fileAdded` events.
async fn start_office_watch(&self, workspace: &str) -> Result<(), AppError>;
/// Stop watching a workspace directory for Office files.
async fn stop_office_watch(&self, workspace: &str) -> Result<(), AppError>;
}
/// Git-based workspace snapshot system for tracking file changes.
///
/// Supports two modes:
/// - **git-repo**: directory already has `.git` — uses it directly.
/// - **snapshot**: no `.git` — creates a temporary repo under
/// `/tmp/nomifun-snapshot-*`.
#[async_trait::async_trait]
pub trait ISnapshotService: Send + Sync {
/// Initialize the snapshot system for a workspace.
/// Auto-detects `git-repo` or `snapshot` mode.
async fn init(&self, workspace: &str) -> Result<SnapshotInfo, AppError>;
/// Get the current snapshot mode and branch info.
async fn get_info(&self, workspace: &str) -> Result<SnapshotInfo, AppError>;
/// Compare workspace state against the baseline.
/// Returns staged and unstaged changes.
async fn compare(&self, workspace: &str) -> Result<CompareResult, AppError>;
/// Get the baseline (HEAD) content of a file.
/// Returns `None` for new/untracked files.
async fn get_baseline_content(&self, workspace: &str, file_path: &str) -> Result<Option<String>, AppError>;
/// Stage a single file (git-repo mode only).
async fn stage_file(&self, workspace: &str, file_path: &str) -> Result<(), AppError>;
/// Stage all changes.
async fn stage_all(&self, workspace: &str) -> Result<(), AppError>;
/// Unstage a single file.
async fn unstage_file(&self, workspace: &str, file_path: &str) -> Result<(), AppError>;
/// Unstage all staged changes.
async fn unstage_all(&self, workspace: &str) -> Result<(), AppError>;
/// Discard changes to a file (restore to baseline).
async fn discard_file(
&self,
workspace: &str,
file_path: &str,
operation: FileChangeOperation,
) -> Result<(), AppError>;
/// Reset a file to its baseline state.
async fn reset_file(
&self,
workspace: &str,
file_path: &str,
operation: FileChangeOperation,
) -> Result<(), AppError>;
/// List git branches (git-repo mode only).
async fn get_branches(&self, workspace: &str) -> Result<Vec<String>, AppError>;
/// Clean up snapshot resources.
/// For snapshot mode, deletes the temporary git repository.
async fn dispose(&self, workspace: &str) -> Result<(), AppError>;
}
/// Convenience alias for an Arc-wrapped file service.
pub type FileServiceRef = Arc<dyn IFileService>;
/// Convenience alias for an Arc-wrapped file watch service.
pub type FileWatchServiceRef = Arc<dyn IFileWatchService>;
/// Convenience alias for an Arc-wrapped snapshot service.
pub type SnapshotServiceRef = Arc<dyn ISnapshotService>;
@@ -0,0 +1,273 @@
use nomifun_common::FileChangeOperation;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// contentUpdate operation (distinct from snapshot FileChangeOperation)
// ---------------------------------------------------------------------------
/// Operation type for `fileStream.contentUpdate` events.
///
/// API Spec mandates exactly two values: `write` and `delete`.
/// This is intentionally separate from [`FileChangeOperation`] which tracks
/// git-style changes (Create/Modify/Delete) in the snapshot system.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ContentUpdateOperation {
Write,
Delete,
}
// ---------------------------------------------------------------------------
// File tree / directory browsing
// ---------------------------------------------------------------------------
/// A node in the directory tree (file or directory with optional children).
///
/// Used internally by `IFileService::get_files_by_dir`. Converted to
/// `DirOrFileResponse` at the API boundary.
#[derive(Debug, Clone, PartialEq)]
pub struct DirOrFile {
pub name: String,
pub full_path: String,
pub relative_path: String,
pub is_dir: bool,
pub children: Vec<DirOrFile>,
}
/// A flat file entry in a workspace listing.
///
/// Used by `IFileService::list_workspace_files`. No children — just path info.
#[derive(Debug, Clone, PartialEq)]
pub struct WorkspaceFlatFile {
pub name: String,
pub full_path: String,
pub relative_path: String,
}
// ---------------------------------------------------------------------------
// File metadata
// ---------------------------------------------------------------------------
/// Metadata for a single file or directory.
#[derive(Debug, Clone)]
pub struct FileMetadata {
pub name: String,
pub path: String,
pub size: u64,
pub mime_type: String,
pub last_modified: i64,
pub is_directory: bool,
}
// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------
/// Payload for the `fileStream.contentUpdate` WebSocket event.
///
/// Emitted after `write_file` (operation = Write) or `remove_entry`
/// (operation = Delete).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentUpdateEvent {
pub file_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
pub workspace: String,
pub relative_path: String,
pub operation: ContentUpdateOperation,
}
/// Payload for the `fileWatch.fileChanged` WebSocket event.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileWatchEvent {
pub file_path: String,
pub event_type: String,
}
/// Payload for the `workspaceOfficeWatch.fileAdded` WebSocket event.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OfficeFileAddedEvent {
pub file_path: String,
pub workspace: String,
}
// ---------------------------------------------------------------------------
// Workspace snapshot
// ---------------------------------------------------------------------------
/// Snapshot mode for a workspace.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SnapshotMode {
/// Directory already has a `.git` — use it directly.
GitRepo,
/// No `.git` — a temporary repo is created under `/tmp/nomifun-snapshot-*`.
Snapshot,
/// Snapshot tracking was refused for this workspace (e.g. drive/fs root,
/// a well-known system directory, or too large to safely snapshot).
/// `reason` is a human-readable explanation surfaced to the client.
Disabled { reason: String },
}
/// Information about a workspace snapshot.
#[derive(Debug, Clone)]
pub struct SnapshotInfo {
pub mode: SnapshotMode,
pub branch: Option<String>,
}
/// A single file change detected by the snapshot system.
#[derive(Debug, Clone, PartialEq)]
pub struct FileChangeInfo {
pub file_path: String,
pub relative_path: String,
pub operation: FileChangeOperation,
}
/// Result of comparing workspace changes against the baseline.
#[derive(Debug, Clone)]
pub struct CompareResult {
pub staged: Vec<FileChangeInfo>,
pub unstaged: Vec<FileChangeInfo>,
}
// ---------------------------------------------------------------------------
// ZIP
// ---------------------------------------------------------------------------
/// A single entry to include in a ZIP archive.
#[derive(Debug, Clone)]
pub enum ZipEntry {
/// In-memory text content.
Text { name: String, content: String },
/// Read from a file on disk.
Disk { name: String, file_path: String },
}
/// Result of a batch copy operation.
#[derive(Debug, Clone)]
pub struct CopyResult {
pub copied_files: Vec<String>,
pub failed_files: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn content_update_event_serialization() {
let event = ContentUpdateEvent {
file_path: "/ws/src/main.rs".into(),
content: Some("fn main() {}".into()),
workspace: "/ws".into(),
relative_path: "src/main.rs".into(),
operation: ContentUpdateOperation::Write,
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["file_path"], "/ws/src/main.rs");
assert_eq!(json["content"], "fn main() {}");
assert_eq!(json["workspace"], "/ws");
assert_eq!(json["relative_path"], "src/main.rs");
assert_eq!(json["operation"], "write");
}
#[test]
fn content_update_event_delete_omits_content() {
let event = ContentUpdateEvent {
file_path: "/ws/old.txt".into(),
content: None,
workspace: "/ws".into(),
relative_path: "old.txt".into(),
operation: ContentUpdateOperation::Delete,
};
let json = serde_json::to_value(&event).unwrap();
assert!(json.get("content").is_none());
assert_eq!(json["operation"], "delete");
}
#[test]
fn file_watch_event_serialization() {
let event = FileWatchEvent {
file_path: "/path/to/file.txt".into(),
event_type: "change".into(),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["file_path"], "/path/to/file.txt");
assert_eq!(json["event_type"], "change");
}
#[test]
fn office_file_added_event_serialization() {
let event = OfficeFileAddedEvent {
file_path: "/ws/report.docx".into(),
workspace: "/ws".into(),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["file_path"], "/ws/report.docx");
assert_eq!(json["workspace"], "/ws");
}
#[test]
fn content_update_event_deserialization() {
let raw = json!({
"file_path": "/ws/a.txt",
"content": "hello",
"workspace": "/ws",
"relative_path": "a.txt",
"operation": "write"
});
let event: ContentUpdateEvent = serde_json::from_value(raw).unwrap();
assert_eq!(event.file_path, "/ws/a.txt");
assert_eq!(event.content.as_deref(), Some("hello"));
assert_eq!(event.operation, ContentUpdateOperation::Write);
}
#[test]
fn snapshot_mode_equality() {
assert_eq!(SnapshotMode::GitRepo, SnapshotMode::GitRepo);
assert_ne!(SnapshotMode::GitRepo, SnapshotMode::Snapshot);
}
#[test]
fn compare_result_empty() {
let result = CompareResult {
staged: vec![],
unstaged: vec![],
};
assert!(result.staged.is_empty());
assert!(result.unstaged.is_empty());
}
#[test]
fn file_change_info_equality() {
let a = FileChangeInfo {
file_path: "/ws/a.txt".into(),
relative_path: "a.txt".into(),
operation: FileChangeOperation::Create,
};
let b = a.clone();
assert_eq!(a, b);
}
#[test]
fn dir_or_file_with_children() {
let dir = DirOrFile {
name: "src".into(),
full_path: "/project/src".into(),
relative_path: "src".into(),
is_dir: true,
children: vec![DirOrFile {
name: "main.rs".into(),
full_path: "/project/src/main.rs".into(),
relative_path: "src/main.rs".into(),
is_dir: false,
children: vec![],
}],
};
assert!(dir.is_dir);
assert_eq!(dir.children.len(), 1);
assert!(!dir.children[0].is_dir);
assert!(dir.children[0].children.is_empty());
}
}
@@ -0,0 +1,404 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use dashmap::DashMap;
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use tracing::warn;
use nomifun_api_types::WebSocketMessage;
use nomifun_common::AppError;
use nomifun_realtime::EventBroadcaster;
use crate::types::{FileWatchEvent, OfficeFileAddedEvent};
/// Debounce duration for file watch events.
const DEBOUNCE_DURATION: Duration = Duration::from_millis(200);
/// Office file extensions to match (lowercase).
const OFFICE_EXTENSIONS: &[&str] = &["pptx", "docx", "xlsx"];
// ---------------------------------------------------------------------------
// Pure helpers (testable without I/O)
// ---------------------------------------------------------------------------
/// Returns `true` if the file path has an Office document extension.
fn is_office_file(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| {
let lower = ext.to_ascii_lowercase();
OFFICE_EXTENSIONS.contains(&lower.as_str())
})
}
/// Maps a `notify::EventKind` to a human-readable event type string.
/// Returns `None` for events that should be silently skipped (e.g. access).
fn event_kind_to_str(kind: &EventKind) -> Option<&'static str> {
match kind {
EventKind::Modify(_) => Some("change"),
EventKind::Create(_) => Some("create"),
EventKind::Remove(_) => Some("remove"),
EventKind::Any | EventKind::Other => Some("change"),
EventKind::Access(_) => None,
}
}
/// Returns `true` if enough time has elapsed since the last event for `key`.
/// Updates the timestamp when returning `true`.
fn should_emit(debounce: &DashMap<String, Instant>, key: &str) -> bool {
let now = Instant::now();
if let Some(last) = debounce.get(key)
&& now.duration_since(*last) < DEBOUNCE_DURATION
{
return false;
}
debounce.insert(key.to_owned(), now);
true
}
// ---------------------------------------------------------------------------
// FileWatchService
// ---------------------------------------------------------------------------
/// File-system watcher implementing [`crate::traits::IFileWatchService`].
///
/// Internally uses the `notify` crate for cross-platform file-system events.
///
/// - **Single-file watches** share one [`RecommendedWatcher`] instance; each
/// path is registered via `watch()` with [`RecursiveMode::NonRecursive`].
/// - **Workspace Office watches** each get their own watcher running in
/// [`RecursiveMode::Recursive`], filtering for `.pptx`/`.docx`/`.xlsx`
/// creation events.
pub struct FileWatchService {
broadcaster: Arc<dyn EventBroadcaster>,
/// Shared watcher for all single-file watches.
file_watcher: Mutex<RecommendedWatcher>,
/// Set of canonical paths being watched (shared with the event handler).
watched_files: Arc<DashMap<String, ()>>,
/// Per-workspace Office watchers, keyed by canonical workspace path.
office_watchers: Mutex<HashMap<String, RecommendedWatcher>>,
/// Debounce timestamps shared with watcher callbacks.
debounce: Arc<DashMap<String, Instant>>,
}
impl FileWatchService {
/// Create a new watch service backed by the platform's recommended watcher.
pub fn new(broadcaster: Arc<dyn EventBroadcaster>) -> Result<Self, AppError> {
let watched_files: Arc<DashMap<String, ()>> = Arc::new(DashMap::new());
let debounce: Arc<DashMap<String, Instant>> = Arc::new(DashMap::new());
let bc = broadcaster.clone();
let wf = watched_files.clone();
let db = debounce.clone();
let file_watcher = notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
let event = match res {
Ok(e) => e,
Err(e) => {
warn!(error = %e, "file watcher error");
return;
}
};
let event_type = match event_kind_to_str(&event.kind) {
Some(t) => t,
None => return,
};
for path in &event.paths {
let path_str = path.to_string_lossy().into_owned();
if !wf.contains_key(&path_str) {
continue;
}
if !should_emit(&db, &path_str) {
continue;
}
let payload = FileWatchEvent {
file_path: path_str,
event_type: event_type.to_owned(),
};
let json = serde_json::to_value(&payload).unwrap_or_default();
bc.broadcast(WebSocketMessage::new("fileWatch.fileChanged", json));
}
})
.map_err(|e| AppError::Internal(format!("failed to create file watcher: {e}")))?;
Ok(Self {
broadcaster,
file_watcher: Mutex::new(file_watcher),
watched_files,
office_watchers: Mutex::new(HashMap::new()),
debounce,
})
}
}
#[async_trait::async_trait]
impl crate::traits::IFileWatchService for FileWatchService {
async fn start_watch(&self, file_path: &str) -> Result<(), AppError> {
let canonical = std::fs::canonicalize(file_path)
.map_err(|e| AppError::NotFound(format!("cannot resolve path {file_path}: {e}")))?;
let key = canonical.to_string_lossy().into_owned();
// Idempotent: already watching → no-op.
if self.watched_files.contains_key(&key) {
return Ok(());
}
let mut watcher = self
.file_watcher
.lock()
.map_err(|e| AppError::Internal(format!("file watcher lock poisoned: {e}")))?;
watcher
.watch(&canonical, RecursiveMode::NonRecursive)
.map_err(|e| AppError::Internal(format!("failed to watch {file_path}: {e}")))?;
self.watched_files.insert(key, ());
Ok(())
}
async fn stop_watch(&self, file_path: &str) -> Result<(), AppError> {
let canonical = std::fs::canonicalize(file_path).unwrap_or_else(|_| file_path.into());
let key = canonical.to_string_lossy().into_owned();
if self.watched_files.remove(&key).is_none() {
return Ok(());
}
let mut watcher = self
.file_watcher
.lock()
.map_err(|e| AppError::Internal(format!("file watcher lock poisoned: {e}")))?;
// Ignore unwatch errors — the file may have been deleted.
let _ = watcher.unwatch(&canonical);
self.debounce.remove(&key);
Ok(())
}
async fn stop_all_watches(&self) -> Result<(), AppError> {
let mut watcher = self
.file_watcher
.lock()
.map_err(|e| AppError::Internal(format!("file watcher lock poisoned: {e}")))?;
for entry in self.watched_files.iter() {
let path = std::path::PathBuf::from(entry.key().as_str());
let _ = watcher.unwatch(&path);
}
self.watched_files.clear();
// Clean file-watch debounce entries only (keep office ones).
self.debounce.retain(|k, _| k.starts_with("office:"));
Ok(())
}
async fn start_office_watch(&self, workspace: &str) -> Result<(), AppError> {
let canonical = std::fs::canonicalize(workspace)
.map_err(|e| AppError::NotFound(format!("cannot resolve workspace {workspace}: {e}")))?;
let key = canonical.to_string_lossy().into_owned();
{
let watchers = self
.office_watchers
.lock()
.map_err(|e| AppError::Internal(format!("office watcher lock poisoned: {e}")))?;
if watchers.contains_key(&key) {
return Ok(());
}
}
let bc = self.broadcaster.clone();
let db = self.debounce.clone();
let ws = key.clone();
let mut watcher = notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
let event = match res {
Ok(e) => e,
Err(e) => {
warn!(error = %e, "office watcher error");
return;
}
};
if !matches!(event.kind, EventKind::Create(_)) {
return;
}
for path in &event.paths {
if !is_office_file(path) {
continue;
}
let path_str = path.to_string_lossy().into_owned();
let debounce_key = format!("office:{path_str}");
if !should_emit(&db, &debounce_key) {
continue;
}
let payload = OfficeFileAddedEvent {
file_path: path_str,
workspace: ws.clone(),
};
let json = serde_json::to_value(&payload).unwrap_or_default();
bc.broadcast(WebSocketMessage::new("workspaceOfficeWatch.fileAdded", json));
}
})
.map_err(|e| AppError::Internal(format!("failed to create office watcher: {e}")))?;
watcher
.watch(&canonical, RecursiveMode::Recursive)
.map_err(|e| AppError::Internal(format!("failed to watch workspace {workspace}: {e}")))?;
let mut watchers = self
.office_watchers
.lock()
.map_err(|e| AppError::Internal(format!("office watcher lock poisoned: {e}")))?;
watchers.insert(key, watcher);
Ok(())
}
async fn stop_office_watch(&self, workspace: &str) -> Result<(), AppError> {
let canonical = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.into());
let key = canonical.to_string_lossy().into_owned();
let mut watchers = self
.office_watchers
.lock()
.map_err(|e| AppError::Internal(format!("office watcher lock poisoned: {e}")))?;
// Dropping the watcher stops watching.
watchers.remove(&key);
Ok(())
}
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{AccessKind, CreateKind, ModifyKind, RemoveKind};
use std::path::PathBuf;
// -- is_office_file --
#[test]
fn office_file_pptx() {
assert!(is_office_file(Path::new("/ws/slides.pptx")));
}
#[test]
fn office_file_docx() {
assert!(is_office_file(Path::new("/ws/report.docx")));
}
#[test]
fn office_file_xlsx() {
assert!(is_office_file(Path::new("/ws/data.xlsx")));
}
#[test]
fn office_file_case_insensitive() {
assert!(is_office_file(Path::new("/ws/FILE.PPTX")));
assert!(is_office_file(Path::new("/ws/Doc.Docx")));
}
#[test]
fn non_office_file_txt() {
assert!(!is_office_file(Path::new("/ws/readme.txt")));
}
#[test]
fn non_office_file_pdf() {
assert!(!is_office_file(Path::new("/ws/paper.pdf")));
}
#[test]
fn no_extension() {
assert!(!is_office_file(Path::new("/ws/Makefile")));
}
// -- event_kind_to_str --
#[test]
fn modify_event_maps_to_change() {
assert_eq!(
event_kind_to_str(&EventKind::Modify(ModifyKind::Data(notify::event::DataChange::Content))),
Some("change")
);
}
#[test]
fn create_event_maps_to_create() {
assert_eq!(event_kind_to_str(&EventKind::Create(CreateKind::File)), Some("create"));
}
#[test]
fn remove_event_maps_to_remove() {
assert_eq!(event_kind_to_str(&EventKind::Remove(RemoveKind::File)), Some("remove"));
}
#[test]
fn any_event_maps_to_change() {
assert_eq!(event_kind_to_str(&EventKind::Any), Some("change"));
}
#[test]
fn other_event_maps_to_change() {
assert_eq!(event_kind_to_str(&EventKind::Other), Some("change"));
}
#[test]
fn access_event_is_skipped() {
assert_eq!(event_kind_to_str(&EventKind::Access(AccessKind::Read)), None);
}
// -- should_emit (debounce) --
#[test]
fn first_emit_returns_true() {
let db = DashMap::new();
assert!(should_emit(&db, "/tmp/a.txt"));
}
#[test]
fn immediate_second_emit_returns_false() {
let db = DashMap::new();
assert!(should_emit(&db, "/tmp/a.txt"));
assert!(!should_emit(&db, "/tmp/a.txt"));
}
#[test]
fn different_keys_are_independent() {
let db = DashMap::new();
assert!(should_emit(&db, "/tmp/a.txt"));
assert!(should_emit(&db, "/tmp/b.txt"));
}
#[test]
fn emit_after_debounce_duration() {
let db = DashMap::new();
assert!(should_emit(&db, "/tmp/a.txt"));
// Simulate time passing by manually backdating the entry.
db.insert(
"/tmp/a.txt".to_owned(),
Instant::now() - DEBOUNCE_DURATION - Duration::from_millis(1),
);
assert!(should_emit(&db, "/tmp/a.txt"));
}
// -- is_office_file edge cases --
#[test]
fn dotfile_with_office_ext() {
assert!(is_office_file(Path::new("/ws/.hidden.docx")));
}
#[test]
fn nested_path_office_file() {
assert!(is_office_file(Path::new("/ws/deep/nested/dir/report.xlsx")));
}
#[test]
fn empty_path() {
assert!(!is_office_file(&PathBuf::new()));
}
}
@@ -0,0 +1,164 @@
//! Single-level, workspace-scoped directory listing shared by the
//! conversation workspace rail (`GET /api/conversations/{id}/workspace`) and
//! the terminal workspace rail (`GET /api/terminals/{id}/workspace`).
//!
//! The caller resolves the workspace root (a conversation's
//! `extra.workspace`, a terminal's cwd, …); this function takes that root plus
//! a relative path and enumerates exactly one directory level under it,
//! enforcing workspace isolation:
//!
//! - reject `..` parent-traversal components in the relative path;
//! - canonicalize and require the browsed path to stay inside the root, with
//! an allowance for symlinked sub-directories mounted inside the workspace
//! (e.g. native skill dirs that point at the builtin skills corpus under the
//! data-dir);
//! - cap relative depth at [`MAX_DIR_DEPTH`];
//! - optional case-insensitive name `search` filter.
//!
//! Entries are returned directories-first, then case-insensitively
//! alphabetical.
use std::path::{Component, Path};
use nomifun_api_types::WorkspaceEntry;
use nomifun_common::AppError;
/// Maximum relative directory depth that may be browsed under a workspace
/// root. Guards against unbounded recursion when a client walks a deep tree.
pub const MAX_DIR_DEPTH: usize = 10;
/// Enumerate a single directory level under `base`, scoped to `rel`.
///
/// `base` is the (already-resolved) workspace root. `rel` is the
/// workspace-relative path to list (`""` or `"/"` lists the root itself).
/// `search`, when set and non-empty, filters entries to names that contain it
/// case-insensitively.
///
/// Returns the directory's entries (directories first, then case-insensitive
/// alphabetical) or an [`AppError`] describing the isolation/IO failure.
pub fn list_workspace_level(
base: &Path,
rel: &str,
search: Option<&str>,
) -> Result<Vec<WorkspaceEntry>, AppError> {
let relative_path = rel.trim_start_matches('/');
let relative_path_obj = Path::new(relative_path);
if relative_path_obj
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err(AppError::BadRequest(
"Path traversal outside workspace is not allowed".into(),
));
}
// Resolve the browsed path relative to the workspace root.
let browse_path = if relative_path.is_empty() {
base.to_path_buf()
} else {
base.join(relative_path_obj)
};
// Security: reject direct traversal outside the workspace root, but allow
// symlinked directories mounted inside the workspace (e.g. native skill
// dirs that point at the builtin skills corpus under data-dir).
let canonical_base = base
.canonicalize()
.map_err(|e| AppError::Internal(format!("Failed to resolve workspace path: {e}")))?;
let canonical_browse = browse_path
.canonicalize()
.map_err(|_| AppError::NotFound("Directory not found".into()))?;
if !browse_path.starts_with(base) && !canonical_browse.starts_with(&canonical_base) {
return Err(AppError::BadRequest(
"Path traversal outside workspace is not allowed".into(),
));
}
// Check depth limit.
let depth = relative_path_obj.components().count();
if depth > MAX_DIR_DEPTH {
return Err(AppError::BadRequest(format!(
"Directory depth exceeds maximum of {MAX_DIR_DEPTH}"
)));
}
let search_lower = search
.filter(|s| !s.is_empty())
.map(|s| s.to_lowercase());
let mut entries = Vec::new();
let dir_reader = std::fs::read_dir(&canonical_browse)
.map_err(|e| AppError::Internal(format!("Failed to read directory: {e}")))?;
for entry in dir_reader {
let entry = entry.map_err(|e| AppError::Internal(format!("Failed to read directory entry: {e}")))?;
let name = entry.file_name().to_string_lossy().into_owned();
// Apply search filter if provided.
if let Some(ref needle) = search_lower
&& !name.to_lowercase().contains(needle)
{
continue;
}
let metadata = std::fs::metadata(entry.path())
.map_err(|e| AppError::Internal(format!("Failed to read entry metadata: {e}")))?;
let entry_type = if metadata.is_dir() { "directory" } else { "file" };
entries.push(WorkspaceEntry {
name,
entry_type: entry_type.into(),
});
}
// Sort: directories first, then alphabetically (case-insensitive).
entries.sort_by(|a, b| {
let type_cmp = a.entry_type.cmp(&b.entry_type);
if type_cmp == std::cmp::Ordering::Equal {
a.name.to_lowercase().cmp(&b.name.to_lowercase())
} else {
type_cmp
}
});
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn lists_one_level_with_type() {
let dir = tempdir().unwrap();
fs::create_dir(dir.path().join("sub")).unwrap();
fs::write(dir.path().join("a.txt"), "x").unwrap();
let mut out = list_workspace_level(dir.path(), "", None).unwrap();
out.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(out.len(), 2);
assert_eq!(out[0].name, "a.txt");
assert_eq!(out[0].entry_type, "file");
assert_eq!(out[1].name, "sub");
assert_eq!(out[1].entry_type, "directory");
}
#[test]
fn rejects_parent_traversal() {
let dir = tempdir().unwrap();
let err = list_workspace_level(dir.path(), "../", None);
assert!(err.is_err(), "`..` must be rejected");
}
#[test]
fn search_filters_case_insensitive() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "x").unwrap();
fs::write(dir.path().join("readme.md"), "x").unwrap();
let out = list_workspace_level(dir.path(), "", Some("cargo")).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "Cargo.toml");
}
}
@@ -0,0 +1,337 @@
//! Integration tests for directory browsing and file metadata (task 7.3).
//!
//! These tests exercise the full `FileService` through the `IFileService` trait,
//! including path validation, .gitignore handling, and caching behavior.
use std::fs;
use std::sync::Arc;
use nomifun_api_types::WebSocketMessage;
use nomifun_file::{FileService, IFileService};
use nomifun_realtime::EventBroadcaster;
/// A no-op broadcaster for testing (events are silently discarded).
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
}
/// Create a `FileService` whose sandbox is rooted at the given temp directory.
fn make_service(root: &std::path::Path) -> FileService {
FileService::new(Arc::new(NoopBroadcaster), vec![root.to_path_buf()])
}
// -----------------------------------------------------------------------
// getFilesByDir
// -----------------------------------------------------------------------
#[tokio::test]
async fn get_files_by_dir_lists_children() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "hello").unwrap();
fs::create_dir(dir.path().join("sub")).unwrap();
fs::write(dir.path().join("sub/nested.txt"), "nested").unwrap();
let svc = make_service(dir.path());
let root = dir.path().to_str().unwrap();
let items = svc.get_files_by_dir(root, root).await.unwrap();
// Should have: sub/ and a.txt
assert_eq!(items.len(), 2);
let sub = items.iter().find(|i| i.name == "sub").unwrap();
assert!(sub.is_dir);
assert_eq!(sub.children.len(), 1);
assert_eq!(sub.children[0].name, "nested.txt");
let file = items.iter().find(|i| i.name == "a.txt").unwrap();
assert!(!file.is_dir);
assert!(file.children.is_empty());
}
#[tokio::test]
async fn get_files_by_dir_empty_directory() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let root = dir.path().to_str().unwrap();
let items = svc.get_files_by_dir(root, root).await.unwrap();
assert!(items.is_empty());
}
#[tokio::test]
async fn get_files_by_dir_subdirectory() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("src");
fs::create_dir(&sub).unwrap();
fs::write(sub.join("main.rs"), "fn main(){}").unwrap();
fs::write(sub.join("lib.rs"), "pub mod foo;").unwrap();
let svc = make_service(dir.path());
let root = dir.path().to_str().unwrap();
let items = svc.get_files_by_dir(sub.to_str().unwrap(), root).await.unwrap();
assert_eq!(items.len(), 2);
// Relative paths should be relative to root, not to sub
assert!(items[0].relative_path.starts_with("src/"));
}
#[tokio::test]
async fn get_files_by_dir_rejects_path_outside_sandbox() {
let sandbox = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
fs::write(outside.path().join("secret.txt"), "secret").unwrap();
let svc = make_service(sandbox.path());
let result = svc
.get_files_by_dir(outside.path().to_str().unwrap(), outside.path().to_str().unwrap())
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_files_by_dir_nonexistent_dir() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("nonexistent");
let svc = make_service(dir.path());
let result = svc
.get_files_by_dir(fake.to_str().unwrap(), dir.path().to_str().unwrap())
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_files_by_dir_directories_sorted_first() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("z_file.txt"), "").unwrap();
fs::create_dir(dir.path().join("a_dir")).unwrap();
fs::write(dir.path().join("a_file.txt"), "").unwrap();
let svc = make_service(dir.path());
let root = dir.path().to_str().unwrap();
let items = svc.get_files_by_dir(root, root).await.unwrap();
// Directory first
assert!(items[0].is_dir);
assert_eq!(items[0].name, "a_dir");
// Then files alphabetically
assert_eq!(items[1].name, "a_file.txt");
assert_eq!(items[2].name, "z_file.txt");
}
// -----------------------------------------------------------------------
// listWorkspaceFiles
// -----------------------------------------------------------------------
#[tokio::test]
async fn list_workspace_files_recursive() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("root.txt"), "").unwrap();
fs::create_dir(dir.path().join("a")).unwrap();
fs::write(dir.path().join("a/nested.txt"), "").unwrap();
fs::create_dir(dir.path().join("a/b")).unwrap();
fs::write(dir.path().join("a/b/deep.txt"), "").unwrap();
let svc = make_service(dir.path());
let files = svc.list_workspace_files(dir.path().to_str().unwrap()).await.unwrap();
assert_eq!(files.len(), 3);
let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"root.txt"));
assert!(names.contains(&"nested.txt"));
assert!(names.contains(&"deep.txt"));
}
#[tokio::test]
async fn list_workspace_files_respects_gitignore() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".gitignore"), "*.log\nbuild/\n").unwrap();
fs::write(dir.path().join("app.rs"), "").unwrap();
fs::write(dir.path().join("debug.log"), "").unwrap();
fs::create_dir(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/output.js"), "").unwrap();
let svc = make_service(dir.path());
let files = svc.list_workspace_files(dir.path().to_str().unwrap()).await.unwrap();
let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"app.rs"));
assert!(names.contains(&".gitignore"));
assert!(!names.contains(&"debug.log"));
assert!(!names.contains(&"output.js"));
}
#[tokio::test]
async fn list_workspace_files_empty_workspace() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let files = svc.list_workspace_files(dir.path().to_str().unwrap()).await.unwrap();
assert!(files.is_empty());
}
#[tokio::test]
async fn list_workspace_files_rejects_outside_sandbox() {
let sandbox = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let svc = make_service(sandbox.path());
let result = svc.list_workspace_files(outside.path().to_str().unwrap()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn list_workspace_files_cache_hit() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("file.txt"), "data").unwrap();
let svc = make_service(dir.path());
let root = dir.path().to_str().unwrap();
// First call populates cache
let first = svc.list_workspace_files(root).await.unwrap();
assert_eq!(first.len(), 1);
// Add a file — should NOT appear due to cache
fs::write(dir.path().join("new.txt"), "new").unwrap();
let second = svc.list_workspace_files(root).await.unwrap();
assert_eq!(second.len(), 1); // Still cached
// Invalidate cache
svc.invalidate_cache(&std::fs::canonicalize(dir.path()).unwrap().to_string_lossy());
// Now should see new file
let third = svc.list_workspace_files(root).await.unwrap();
assert_eq!(third.len(), 2);
}
#[tokio::test]
async fn list_workspace_files_relative_paths() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/utils")).unwrap();
fs::write(dir.path().join("src/utils/helper.ts"), "").unwrap();
let svc = make_service(dir.path());
let files = svc.list_workspace_files(dir.path().to_str().unwrap()).await.unwrap();
let helper = files.iter().find(|f| f.name == "helper.ts").unwrap();
assert_eq!(helper.relative_path, "src/utils/helper.ts");
}
#[cfg(unix)]
#[tokio::test]
async fn list_workspace_files_skips_directory_symlinks() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("builtin-skills/auto-inject/nomifun-skills");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "---\ndescription: test\n---\nbody").unwrap();
let workspace_skills_dir = dir.path().join("workspace/.claude/skills");
fs::create_dir_all(&workspace_skills_dir).unwrap();
std::os::unix::fs::symlink(&skill_dir, workspace_skills_dir.join("nomifun-skills")).unwrap();
let svc = make_service(dir.path().join("workspace").as_path());
let files = svc
.list_workspace_files(dir.path().join("workspace").to_str().unwrap())
.await
.unwrap();
assert!(
files.iter().all(|file| file.name != "nomifun-skills"),
"directory symlink should not be surfaced as a file: {files:?}"
);
}
// -----------------------------------------------------------------------
// getFileMetadata
// -----------------------------------------------------------------------
#[tokio::test]
async fn get_file_metadata_text_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hello.txt");
fs::write(&file, "hello world").unwrap();
let svc = make_service(dir.path());
let meta = svc.get_file_metadata(file.to_str().unwrap(), None).await.unwrap();
assert_eq!(meta.name, "hello.txt");
assert_eq!(meta.size, 11);
assert_eq!(meta.mime_type, "text/plain");
assert!(!meta.is_directory);
assert!(meta.last_modified > 0);
}
#[tokio::test]
async fn get_file_metadata_image() {
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("photo.png");
fs::write(&png, [0x89, 0x50, 0x4E, 0x47]).unwrap();
let svc = make_service(dir.path());
let meta = svc.get_file_metadata(png.to_str().unwrap(), None).await.unwrap();
assert_eq!(meta.mime_type, "image/png");
}
#[tokio::test]
async fn get_file_metadata_directory() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("mydir");
fs::create_dir(&sub).unwrap();
let svc = make_service(dir.path());
let meta = svc.get_file_metadata(sub.to_str().unwrap(), None).await.unwrap();
assert!(meta.is_directory);
assert_eq!(meta.mime_type, "inode/directory");
assert_eq!(meta.name, "mydir");
}
#[tokio::test]
async fn get_file_metadata_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("missing.txt");
let svc = make_service(dir.path());
let result = svc.get_file_metadata(fake.to_str().unwrap(), None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_file_metadata_outside_sandbox() {
let sandbox = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let secret = outside.path().join("secret.txt");
fs::write(&secret, "secret").unwrap();
let svc = make_service(sandbox.path());
let result = svc.get_file_metadata(secret.to_str().unwrap(), None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_file_metadata_json_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("config.json");
fs::write(&file, r#"{"key":"value"}"#).unwrap();
let svc = make_service(dir.path());
let meta = svc.get_file_metadata(file.to_str().unwrap(), None).await.unwrap();
assert_eq!(meta.mime_type, "application/json");
}
@@ -0,0 +1,452 @@
//! Integration tests for file management operations (task 7.5).
//!
//! Covers `copy_files_to_workspace`, `remove_entry`, `rename_entry`, and
//! `create_temp_file` through the `IFileService` trait, including path
//! validation, event broadcast, and cache invalidation.
use std::fs;
use std::sync::{Arc, Mutex};
use nomifun_api_types::WebSocketMessage;
use nomifun_file::{FileService, IFileService};
use nomifun_realtime::EventBroadcaster;
// -----------------------------------------------------------------------
// Test helpers (shared with file_read_write.rs pattern)
// -----------------------------------------------------------------------
struct RecordingBroadcaster {
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl RecordingBroadcaster {
fn new() -> Self {
Self {
events: Mutex::new(Vec::new()),
}
}
fn take_events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
let mut guard = self.events.lock().unwrap();
std::mem::take(&mut *guard)
}
}
impl EventBroadcaster for RecordingBroadcaster {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(event);
}
}
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
}
fn make_service(root: &std::path::Path) -> FileService {
FileService::new(Arc::new(NoopBroadcaster), vec![root.to_path_buf()])
}
fn make_service_with_recorder(root: &std::path::Path) -> (FileService, Arc<RecordingBroadcaster>) {
let recorder = Arc::new(RecordingBroadcaster::new());
let svc = FileService::new(recorder.clone(), vec![root.to_path_buf()]);
(svc, recorder)
}
// -----------------------------------------------------------------------
// copyFilesToWorkspace
// -----------------------------------------------------------------------
#[tokio::test]
async fn copy_files_single_file() {
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
let ws_dir = dir.path().join("ws");
fs::create_dir_all(&src_dir).unwrap();
fs::create_dir_all(&ws_dir).unwrap();
fs::write(src_dir.join("a.txt"), "hello").unwrap();
let svc = make_service(dir.path());
let paths = vec![src_dir.join("a.txt").to_string_lossy().into_owned()];
let result = svc
.copy_files_to_workspace(&paths, ws_dir.to_str().unwrap(), None)
.await
.unwrap();
assert_eq!(result.copied_files.len(), 1);
assert!(result.failed_files.is_empty());
// Without source_root, file should be at workspace root
assert_eq!(fs::read_to_string(ws_dir.join("a.txt")).unwrap(), "hello");
}
#[tokio::test]
async fn copy_files_with_source_root_preserves_structure() {
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("project");
let ws_dir = dir.path().join("ws");
fs::create_dir_all(src_dir.join("utils")).unwrap();
fs::create_dir_all(&ws_dir).unwrap();
fs::write(src_dir.join("utils/helper.ts"), "export {}").unwrap();
fs::write(src_dir.join("index.ts"), "import {}").unwrap();
let svc = make_service(dir.path());
let paths = vec![
src_dir.join("utils/helper.ts").to_string_lossy().into_owned(),
src_dir.join("index.ts").to_string_lossy().into_owned(),
];
let result = svc
.copy_files_to_workspace(&paths, ws_dir.to_str().unwrap(), Some(src_dir.to_str().unwrap()))
.await
.unwrap();
assert_eq!(result.copied_files.len(), 2);
assert!(result.failed_files.is_empty());
// Directory structure preserved relative to source_root
assert_eq!(fs::read_to_string(ws_dir.join("utils/helper.ts")).unwrap(), "export {}");
assert_eq!(fs::read_to_string(ws_dir.join("index.ts")).unwrap(), "import {}");
}
#[tokio::test]
async fn copy_files_partial_failure() {
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
let ws_dir = dir.path().join("ws");
fs::create_dir_all(&src_dir).unwrap();
fs::create_dir_all(&ws_dir).unwrap();
fs::write(src_dir.join("good.txt"), "ok").unwrap();
let svc = make_service(dir.path());
let paths = vec![
src_dir.join("good.txt").to_string_lossy().into_owned(),
src_dir.join("missing.txt").to_string_lossy().into_owned(),
];
let result = svc
.copy_files_to_workspace(&paths, ws_dir.to_str().unwrap(), None)
.await
.unwrap();
assert_eq!(result.copied_files.len(), 1);
assert_eq!(result.failed_files.len(), 1);
assert!(result.failed_files[0].contains("missing.txt"));
}
#[tokio::test]
async fn copy_files_empty_list() {
let dir = tempfile::tempdir().unwrap();
let ws_dir = dir.path().join("ws");
fs::create_dir_all(&ws_dir).unwrap();
let svc = make_service(dir.path());
let result = svc
.copy_files_to_workspace(&[], ws_dir.to_str().unwrap(), None)
.await
.unwrap();
assert!(result.copied_files.is_empty());
assert!(result.failed_files.is_empty());
}
#[tokio::test]
async fn copy_files_directory_in_list_is_failed() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("subdir");
let ws = dir.path().join("ws");
fs::create_dir_all(&sub).unwrap();
fs::create_dir_all(&ws).unwrap();
let svc = make_service(dir.path());
let paths = vec![sub.to_string_lossy().into_owned()];
let result = svc
.copy_files_to_workspace(&paths, ws.to_str().unwrap(), None)
.await
.unwrap();
// Directories are not valid source files
assert!(result.copied_files.is_empty());
assert_eq!(result.failed_files.len(), 1);
}
#[tokio::test]
async fn copy_files_outside_sandbox_fails() {
let sandbox = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let ws = sandbox.path().join("ws");
fs::create_dir_all(&ws).unwrap();
fs::write(outside.path().join("secret.txt"), "secret").unwrap();
let svc = make_service(sandbox.path());
let paths = vec![outside.path().join("secret.txt").to_string_lossy().into_owned()];
let result = svc
.copy_files_to_workspace(&paths, ws.to_str().unwrap(), None)
.await
.unwrap();
assert!(result.copied_files.is_empty());
assert_eq!(result.failed_files.len(), 1);
}
// -----------------------------------------------------------------------
// removeEntry
// -----------------------------------------------------------------------
#[tokio::test]
async fn remove_entry_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("to_delete.txt");
fs::write(&file, "bye").unwrap();
assert!(file.exists());
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
svc.remove_entry(file.to_str().unwrap(), ws).await.unwrap();
assert!(!file.exists());
}
#[tokio::test]
async fn remove_entry_directory() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("subdir");
fs::create_dir(&sub).unwrap();
fs::write(sub.join("inner.txt"), "data").unwrap();
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
svc.remove_entry(sub.to_str().unwrap(), ws).await.unwrap();
assert!(!sub.exists());
}
#[tokio::test]
async fn remove_entry_nonexistent_errors() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("ghost.txt");
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
let result = svc.remove_entry(fake.to_str().unwrap(), ws).await;
assert!(result.is_err());
}
#[tokio::test]
async fn remove_entry_emits_delete_event() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("event_del.txt");
fs::write(&file, "data").unwrap();
let (svc, recorder) = make_service_with_recorder(dir.path());
let ws = dir.path().to_str().unwrap();
svc.remove_entry(file.to_str().unwrap(), ws).await.unwrap();
let events = recorder.take_events();
assert_eq!(events.len(), 1);
let event = &events[0];
assert_eq!(event.name, "fileStream.contentUpdate");
assert_eq!(event.data["operation"], "delete");
assert!(event.data.get("content").is_none());
assert_eq!(event.data["relative_path"], "event_del.txt");
assert!(event.data["file_path"].as_str().unwrap().contains("event_del.txt"));
}
#[tokio::test]
async fn remove_entry_invalidates_cache() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "a").unwrap();
fs::write(dir.path().join("b.txt"), "b").unwrap();
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
// Populate cache
let files = svc.list_workspace_files(ws).await.unwrap();
assert_eq!(files.len(), 2);
// Remove a file
let target = dir.path().join("a.txt");
svc.remove_entry(target.to_str().unwrap(), ws).await.unwrap();
// Cache should be invalidated, so we see only 1 file
let files = svc.list_workspace_files(ws).await.unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].name, "b.txt");
}
#[tokio::test]
async fn remove_entry_path_traversal_rejected() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
let result = svc.remove_entry("../../etc/passwd", ws).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("traversal"), "got: {err}");
}
// -----------------------------------------------------------------------
// renameEntry
// -----------------------------------------------------------------------
#[tokio::test]
async fn rename_entry_file() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("old.txt");
fs::write(&old, "data").unwrap();
let svc = make_service(dir.path());
let new_path = svc.rename_entry(old.to_str().unwrap(), "new.txt").await.unwrap();
assert!(!old.exists());
assert!(new_path.contains("new.txt"));
assert_eq!(fs::read_to_string(dir.path().join("new.txt")).unwrap(), "data");
}
#[tokio::test]
async fn rename_entry_directory() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("old_dir");
fs::create_dir(&old).unwrap();
fs::write(old.join("inner.txt"), "inner").unwrap();
let svc = make_service(dir.path());
let new_path = svc.rename_entry(old.to_str().unwrap(), "new_dir").await.unwrap();
assert!(!old.exists());
assert!(std::path::Path::new(&new_path).is_dir());
assert_eq!(
fs::read_to_string(dir.path().join("new_dir/inner.txt")).unwrap(),
"inner"
);
}
#[tokio::test]
async fn rename_entry_target_exists_errors() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("old.txt");
let existing = dir.path().join("existing.txt");
fs::write(&old, "old").unwrap();
fs::write(&existing, "existing").unwrap();
let svc = make_service(dir.path());
let result = svc.rename_entry(old.to_str().unwrap(), "existing.txt").await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("already exists"), "got: {err}");
}
#[tokio::test]
async fn rename_entry_nonexistent_source_errors() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("missing.txt");
let svc = make_service(dir.path());
let result = svc.rename_entry(fake.to_str().unwrap(), "new.txt").await;
assert!(result.is_err());
}
#[tokio::test]
async fn rename_entry_path_traversal_rejected() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.rename_entry("../../etc/passwd", "new.txt").await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("traversal"), "got: {err}");
}
#[tokio::test]
async fn rename_entry_rejects_path_separator_in_name() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("a.txt");
fs::write(&file, "data").unwrap();
let svc = make_service(dir.path());
let result = svc.rename_entry(file.to_str().unwrap(), "sub/new.txt").await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("path separator"), "got: {err}");
}
// -----------------------------------------------------------------------
// createTempFile
// -----------------------------------------------------------------------
#[tokio::test]
async fn create_temp_file_normal() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let path = svc.create_temp_file("test.txt").await.unwrap();
assert!(path.contains("test.txt"));
assert!(std::path::Path::new(&path).exists());
}
#[tokio::test]
async fn create_temp_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let path = svc.create_temp_file("empty.txt").await.unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.is_empty());
}
#[tokio::test]
async fn create_temp_file_path_in_nomifun_dir() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let path = svc.create_temp_file("check.txt").await.unwrap();
assert!(path.contains("nomifun"), "temp path should be under nomifun dir");
}
#[tokio::test]
async fn create_temp_file_rejects_traversal() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.create_temp_file("../../malicious.txt").await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("traversal"), "expected traversal error, got: {err}");
}
#[tokio::test]
async fn create_temp_file_rejects_path_separator() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.create_temp_file("sub/file.txt").await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("path separator"),
"expected path separator error, got: {err}"
);
}
#[tokio::test]
async fn create_temp_file_rejects_null_byte() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.create_temp_file("evil\0name.txt").await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("traversal"), "expected traversal error, got: {err}");
}
@@ -0,0 +1,459 @@
//! Integration tests for file read/write operations (task 7.4).
//!
//! These tests exercise `read_file`, `read_file_buffer`, and `write_file`
//! through the `IFileService` trait, including path validation, 256 MB size
//! limit, non-existent file handling, and contentUpdate event broadcast.
use std::fs;
use std::sync::{Arc, Mutex};
use nomifun_api_types::WebSocketMessage;
use nomifun_file::{FileService, IFileService};
use nomifun_realtime::EventBroadcaster;
/// A broadcaster that records every event for later assertion.
struct RecordingBroadcaster {
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl RecordingBroadcaster {
fn new() -> Self {
Self {
events: Mutex::new(Vec::new()),
}
}
fn take_events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
let mut guard = self.events.lock().unwrap();
std::mem::take(&mut *guard)
}
}
impl EventBroadcaster for RecordingBroadcaster {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(event);
}
}
/// No-op broadcaster for tests that don't need event verification.
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
}
fn make_service(root: &std::path::Path) -> FileService {
FileService::new(Arc::new(NoopBroadcaster), vec![root.to_path_buf()])
}
fn make_service_with_recorder(root: &std::path::Path) -> (FileService, Arc<RecordingBroadcaster>) {
let recorder = Arc::new(RecordingBroadcaster::new());
let svc = FileService::new(recorder.clone(), vec![root.to_path_buf()]);
(svc, recorder)
}
// -----------------------------------------------------------------------
// readFile
// -----------------------------------------------------------------------
#[tokio::test]
async fn read_file_normal_utf8() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hello.txt");
fs::write(&file, "hello world").unwrap();
let svc = make_service(dir.path());
let result = svc.read_file(file.to_str().unwrap(), None).await.unwrap();
assert_eq!(result.as_deref(), Some("hello world"));
}
#[tokio::test]
async fn read_file_empty() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("empty.txt");
fs::write(&file, "").unwrap();
let svc = make_service(dir.path());
let result = svc.read_file(file.to_str().unwrap(), None).await.unwrap();
assert_eq!(result.as_deref(), Some(""));
}
#[tokio::test]
async fn read_file_nonexistent_returns_none() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("missing.txt");
let svc = make_service(dir.path());
let result = svc.read_file(fake.to_str().unwrap(), None).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn read_file_path_traversal_rejected() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.read_file("../../etc/passwd", None).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("traversal"), "expected traversal error, got: {err}");
}
#[tokio::test]
async fn read_file_multiline_content() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("multi.txt");
let content = "line 1\nline 2\nline 3\n";
fs::write(&file, content).unwrap();
let svc = make_service(dir.path());
let result = svc.read_file(file.to_str().unwrap(), None).await.unwrap();
assert_eq!(result.as_deref(), Some(content));
}
#[tokio::test]
async fn read_file_unicode_content() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("unicode.txt");
let content = "你好世界 🌍 café résumé";
fs::write(&file, content).unwrap();
let svc = make_service(dir.path());
let result = svc.read_file(file.to_str().unwrap(), None).await.unwrap();
assert_eq!(result.as_deref(), Some(content));
}
#[tokio::test]
async fn read_file_with_extra_workspace_root_outside_home() {
let sandbox = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let file = workspace.path().join("outside.txt");
fs::write(&file, "workspace content").unwrap();
let svc = make_service(sandbox.path());
let result = svc
.read_file(file.to_str().unwrap(), Some(workspace.path()))
.await
.unwrap();
assert_eq!(result.as_deref(), Some("workspace content"));
}
#[tokio::test]
async fn read_file_rejects_outside_sandbox_without_workspace() {
let sandbox = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let file = outside.path().join("secret.txt");
fs::write(&file, "secret").unwrap();
let svc = make_service(sandbox.path());
let err = svc.read_file(file.to_str().unwrap(), None).await.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::Forbidden(_)));
assert_eq!(err.error_code(), "PATH_OUTSIDE_SANDBOX");
}
#[tokio::test]
async fn read_file_returns_none_for_missing_file_in_sandbox() {
let sandbox = tempfile::tempdir().unwrap();
let missing = sandbox.path().join("missing.txt");
let svc = make_service(sandbox.path());
let result = svc.read_file(missing.to_str().unwrap(), None).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn read_file_rejects_directory() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("nomifun-skills");
fs::create_dir(&folder).unwrap();
let svc = make_service(dir.path());
let err = svc.read_file(folder.to_str().unwrap(), None).await.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::BadRequest(_)));
assert!(err.to_string().contains("is a directory"));
}
#[tokio::test]
async fn read_file_buffer_with_extra_workspace_root() {
let sandbox = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let file = workspace.path().join("outside.bin");
let bytes = vec![1, 2, 3, 4];
fs::write(&file, &bytes).unwrap();
let svc = make_service(sandbox.path());
let result = svc
.read_file_buffer(file.to_str().unwrap(), Some(workspace.path()))
.await
.unwrap();
assert_eq!(result.as_deref(), Some(bytes.as_slice()));
}
#[tokio::test]
async fn read_file_nonexistent_inside_workspace_prefix_returns_none() {
let sandbox = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let missing = workspace.path().join("missing.txt");
let svc = make_service(sandbox.path());
let result = svc
.read_file(missing.to_str().unwrap(), Some(workspace.path()))
.await
.unwrap();
assert!(result.is_none());
}
// -----------------------------------------------------------------------
// readFileBuffer
// -----------------------------------------------------------------------
#[tokio::test]
async fn read_file_buffer_normal() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("data.bin");
let data: Vec<u8> = vec![0x00, 0xFF, 0x42, 0x89, 0x50];
fs::write(&file, &data).unwrap();
let svc = make_service(dir.path());
let result = svc.read_file_buffer(file.to_str().unwrap(), None).await.unwrap();
assert_eq!(result.as_deref(), Some(data.as_slice()));
}
#[tokio::test]
async fn read_file_buffer_nonexistent_returns_none() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("missing.bin");
let svc = make_service(dir.path());
let result = svc.read_file_buffer(fake.to_str().unwrap(), None).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn read_file_buffer_path_traversal_rejected() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.read_file_buffer("../../etc/passwd", None).await;
assert!(result.is_err());
}
// -----------------------------------------------------------------------
// writeFile
// -----------------------------------------------------------------------
#[tokio::test]
async fn write_file_normal() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("output.txt");
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
let ok = svc.write_file(file.to_str().unwrap(), b"hello", ws).await.unwrap();
assert!(ok);
assert_eq!(fs::read_to_string(&file).unwrap(), "hello");
}
#[tokio::test]
async fn write_file_creates_new_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("new_file.txt");
assert!(!file.exists());
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
let ok = svc.write_file(file.to_str().unwrap(), b"created", ws).await.unwrap();
assert!(ok);
assert!(file.exists());
assert_eq!(fs::read_to_string(&file).unwrap(), "created");
}
#[tokio::test]
async fn write_file_parent_not_exists_returns_error() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("nonexistent_dir/file.txt");
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
let result = svc.write_file(file.to_str().unwrap(), b"data", ws).await;
assert!(result.is_err());
}
#[tokio::test]
async fn write_file_path_traversal_rejected() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
let result = svc.write_file("../../tmp/evil.txt", b"bad", ws).await;
assert!(result.is_err());
}
#[tokio::test]
async fn write_file_outside_sandbox_rejected() {
let sandbox = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("evil.txt");
let svc = make_service(sandbox.path());
let ws = sandbox.path().to_str().unwrap();
let result = svc.write_file(target.to_str().unwrap(), b"bad", ws).await;
assert!(result.is_err());
}
// -----------------------------------------------------------------------
// contentUpdate event
// -----------------------------------------------------------------------
#[tokio::test]
async fn write_file_emits_content_update_event() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("event_test.txt");
let (svc, recorder) = make_service_with_recorder(dir.path());
let ws = dir.path().to_str().unwrap();
svc.write_file(file.to_str().unwrap(), b"event content", ws)
.await
.unwrap();
let events = recorder.take_events();
assert_eq!(events.len(), 1);
let event = &events[0];
assert_eq!(event.name, "fileStream.contentUpdate");
assert_eq!(event.data["content"], "event content");
assert_eq!(event.data["workspace"], ws);
assert_eq!(event.data["operation"], "write");
// file_path should be the canonical path
assert!(
event.data["file_path"].as_str().unwrap().contains("event_test.txt"),
"file_path should contain the file name"
);
// relative_path should be relative to workspace
assert_eq!(event.data["relative_path"], "event_test.txt");
}
#[tokio::test]
async fn write_file_binary_omits_content_in_event() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("binary.bin");
// Invalid UTF-8 sequence
let data: Vec<u8> = vec![0xFF, 0xFE, 0x00, 0x01];
let (svc, recorder) = make_service_with_recorder(dir.path());
let ws = dir.path().to_str().unwrap();
svc.write_file(file.to_str().unwrap(), &data, ws).await.unwrap();
let events = recorder.take_events();
assert_eq!(events.len(), 1);
let event = &events[0];
// content should be absent for binary data (not valid UTF-8)
assert!(
event.data.get("content").is_none(),
"binary write should omit content in event"
);
}
#[tokio::test]
async fn write_file_nested_relative_path() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/utils")).unwrap();
let file = dir.path().join("src/utils/helper.ts");
let (svc, recorder) = make_service_with_recorder(dir.path());
let ws = dir.path().to_str().unwrap();
svc.write_file(file.to_str().unwrap(), b"export {}", ws).await.unwrap();
let events = recorder.take_events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].data["relative_path"], "src/utils/helper.ts");
}
// -----------------------------------------------------------------------
// read after write (roundtrip)
// -----------------------------------------------------------------------
#[tokio::test]
async fn read_after_write_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("roundtrip.txt");
let content = "roundtrip test content 你好";
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
// Write
let ok = svc
.write_file(file.to_str().unwrap(), content.as_bytes(), ws)
.await
.unwrap();
assert!(ok);
// Read back
let read_result = svc.read_file(file.to_str().unwrap(), None).await.unwrap();
assert_eq!(read_result.as_deref(), Some(content));
}
#[tokio::test]
async fn read_buffer_after_write_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("roundtrip.bin");
let data: Vec<u8> = vec![0x01, 0x02, 0x03, 0xFF, 0xFE];
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
svc.write_file(file.to_str().unwrap(), &data, ws).await.unwrap();
let read_result = svc.read_file_buffer(file.to_str().unwrap(), None).await.unwrap();
assert_eq!(read_result.as_deref(), Some(data.as_slice()));
}
// -----------------------------------------------------------------------
// write_file invalidates workspace files cache
// -----------------------------------------------------------------------
#[tokio::test]
async fn write_file_invalidates_cache() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("existing.txt"), "data").unwrap();
let svc = make_service(dir.path());
let ws = dir.path().to_str().unwrap();
// Populate cache
let files = svc.list_workspace_files(ws).await.unwrap();
assert_eq!(files.len(), 1);
// Write a new file (should invalidate cache)
let new_file = dir.path().join("new.txt");
svc.write_file(new_file.to_str().unwrap(), b"new", ws).await.unwrap();
// Cache should be invalidated, so we see the new file
let files = svc.list_workspace_files(ws).await.unwrap();
assert_eq!(files.len(), 2);
}
@@ -0,0 +1,289 @@
//! Integration tests for file watching (task 7.8).
//!
//! Tests exercise `IFileWatchService` through `FileWatchService`, verifying
//! that filesystem changes produce the expected broadcast events.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use nomifun_api_types::WebSocketMessage;
use nomifun_file::{FileWatchService, IFileWatchService};
use nomifun_realtime::EventBroadcaster;
// -----------------------------------------------------------------------
// Test helpers
// -----------------------------------------------------------------------
/// A broadcaster that records every event for later assertion.
struct RecordingBroadcaster {
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl RecordingBroadcaster {
fn new() -> Self {
Self {
events: Mutex::new(Vec::new()),
}
}
/// Drain all recorded events.
fn take_events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
let mut guard = self.events.lock().unwrap();
std::mem::take(&mut *guard)
}
}
impl EventBroadcaster for RecordingBroadcaster {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(event);
}
}
fn make_service() -> (Arc<dyn IFileWatchService>, Arc<RecordingBroadcaster>) {
let recorder = Arc::new(RecordingBroadcaster::new());
let svc = FileWatchService::new(recorder.clone()).unwrap();
(Arc::new(svc), recorder)
}
/// Wait a bit for the OS file-system event to propagate and the watcher
/// callback to fire. File-system notifications are inherently asynchronous.
async fn settle() {
tokio::time::sleep(Duration::from_millis(500)).await;
}
// -----------------------------------------------------------------------
// Single-file watching
// -----------------------------------------------------------------------
#[tokio::test]
async fn start_watch_and_detect_change() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("watched.txt");
std::fs::write(&file, "initial").unwrap();
let (svc, recorder) = make_service();
svc.start_watch(file.to_str().unwrap()).await.unwrap();
// Modify the file.
settle().await;
std::fs::write(&file, "updated").unwrap();
settle().await;
let events = recorder.take_events();
assert!(
events.iter().any(|e| e.name == "fileWatch.fileChanged"),
"expected fileWatch.fileChanged event, got: {events:?}"
);
let ev = events.iter().find(|e| e.name == "fileWatch.fileChanged").unwrap();
assert!(ev.data["file_path"].as_str().is_some());
assert!(ev.data["event_type"].as_str().is_some());
}
#[tokio::test]
async fn stop_watch_stops_events() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("stop_me.txt");
std::fs::write(&file, "v1").unwrap();
let (svc, recorder) = make_service();
let path_str = file.to_str().unwrap();
svc.start_watch(path_str).await.unwrap();
settle().await;
svc.stop_watch(path_str).await.unwrap();
// Drain any events from the watch setup.
recorder.take_events();
// Modify after stop — should NOT produce events.
std::fs::write(&file, "v2").unwrap();
settle().await;
let events = recorder.take_events();
assert!(events.is_empty(), "expected no events after stop, got: {events:?}");
}
#[tokio::test]
async fn stop_all_watches_clears_file_watches() {
let dir = tempfile::tempdir().unwrap();
let file_a = dir.path().join("a.txt");
let file_b = dir.path().join("b.txt");
std::fs::write(&file_a, "a").unwrap();
std::fs::write(&file_b, "b").unwrap();
let (svc, recorder) = make_service();
svc.start_watch(file_a.to_str().unwrap()).await.unwrap();
svc.start_watch(file_b.to_str().unwrap()).await.unwrap();
settle().await;
svc.stop_all_watches().await.unwrap();
recorder.take_events();
std::fs::write(&file_a, "a2").unwrap();
std::fs::write(&file_b, "b2").unwrap();
settle().await;
let events = recorder.take_events();
assert!(events.is_empty(), "expected no events after stop_all, got: {events:?}");
}
#[tokio::test]
async fn idempotent_start_watch() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("idem.txt");
std::fs::write(&file, "x").unwrap();
let (svc, _recorder) = make_service();
let path_str = file.to_str().unwrap();
svc.start_watch(path_str).await.unwrap();
// Second start should be a no-op, not an error.
svc.start_watch(path_str).await.unwrap();
}
#[tokio::test]
async fn watch_nonexistent_file_returns_error() {
let (svc, _recorder) = make_service();
let result = svc.start_watch("/tmp/nonexistent_12345.txt").await;
assert!(result.is_err());
}
// -----------------------------------------------------------------------
// Workspace Office file watching
// -----------------------------------------------------------------------
#[tokio::test]
async fn office_watch_detects_docx() {
let dir = tempfile::tempdir().unwrap();
let (svc, recorder) = make_service();
svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap();
settle().await;
// Create a .docx file.
std::fs::write(dir.path().join("report.docx"), "fake docx").unwrap();
settle().await;
let events = recorder.take_events();
assert!(
events.iter().any(|e| e.name == "workspaceOfficeWatch.fileAdded"),
"expected workspaceOfficeWatch.fileAdded event, got: {events:?}"
);
}
#[tokio::test]
async fn office_watch_detects_xlsx() {
let dir = tempfile::tempdir().unwrap();
let (svc, recorder) = make_service();
svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap();
settle().await;
std::fs::write(dir.path().join("data.xlsx"), "fake xlsx").unwrap();
settle().await;
let events = recorder.take_events();
assert!(
events.iter().any(|e| e.name == "workspaceOfficeWatch.fileAdded"),
"expected fileAdded for .xlsx, got: {events:?}"
);
}
#[tokio::test]
async fn office_watch_detects_pptx() {
let dir = tempfile::tempdir().unwrap();
let (svc, recorder) = make_service();
svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap();
settle().await;
std::fs::write(dir.path().join("slides.pptx"), "fake pptx").unwrap();
settle().await;
let events = recorder.take_events();
assert!(
events.iter().any(|e| e.name == "workspaceOfficeWatch.fileAdded"),
"expected fileAdded for .pptx, got: {events:?}"
);
}
#[tokio::test]
async fn office_watch_ignores_non_office_files() {
let dir = tempfile::tempdir().unwrap();
let (svc, recorder) = make_service();
svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap();
settle().await;
// Drain any setup events.
recorder.take_events();
// Create a non-Office file — should NOT trigger.
std::fs::write(dir.path().join("notes.txt"), "hello").unwrap();
settle().await;
let events = recorder.take_events();
let office_events: Vec<_> = events
.iter()
.filter(|e| e.name == "workspaceOfficeWatch.fileAdded")
.collect();
assert!(
office_events.is_empty(),
"expected no office events for .txt, got: {office_events:?}"
);
}
#[tokio::test]
async fn stop_office_watch_stops_events() {
let dir = tempfile::tempdir().unwrap();
let (svc, recorder) = make_service();
let ws = dir.path().to_str().unwrap();
svc.start_office_watch(ws).await.unwrap();
settle().await;
svc.stop_office_watch(ws).await.unwrap();
recorder.take_events();
std::fs::write(dir.path().join("after_stop.docx"), "data").unwrap();
settle().await;
let events = recorder.take_events();
let office_events: Vec<_> = events
.iter()
.filter(|e| e.name == "workspaceOfficeWatch.fileAdded")
.collect();
assert!(
office_events.is_empty(),
"expected no events after stop, got: {office_events:?}"
);
}
#[tokio::test]
async fn idempotent_office_watch() {
let dir = tempfile::tempdir().unwrap();
let (svc, _recorder) = make_service();
let ws = dir.path().to_str().unwrap();
svc.start_office_watch(ws).await.unwrap();
// Second call should be a no-op.
svc.start_office_watch(ws).await.unwrap();
}
#[tokio::test]
async fn office_watch_event_has_correct_fields() {
let dir = tempfile::tempdir().unwrap();
let (svc, recorder) = make_service();
svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap();
settle().await;
std::fs::write(dir.path().join("check.docx"), "content").unwrap();
settle().await;
let events = recorder.take_events();
let ev = events.iter().find(|e| e.name == "workspaceOfficeWatch.fileAdded");
assert!(ev.is_some(), "expected fileAdded event, got: {events:?}");
let data = &ev.unwrap().data;
assert!(
data["file_path"].as_str().is_some_and(|p| p.ends_with("check.docx")),
"file_path should end with check.docx: {data:?}"
);
assert!(
data["workspace"].as_str().is_some(),
"workspace should be present: {data:?}"
);
}
@@ -0,0 +1,227 @@
//! Integration tests for image processing operations (task 7.6).
//!
//! These tests exercise `get_image_base64` and `fetch_remote_image`
//! through the `IFileService` trait, covering local image encoding,
//! remote image fetching with whitelist/protocol/size validation,
//! and placeholder SVG fallback behavior.
use std::fs;
use std::sync::Arc;
use base64::Engine;
use nomifun_api_types::WebSocketMessage;
use nomifun_file::{FileService, IFileService};
use nomifun_realtime::EventBroadcaster;
/// No-op broadcaster for tests that don't need event verification.
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
}
fn make_service(root: &std::path::Path) -> FileService {
FileService::new(Arc::new(NoopBroadcaster), vec![root.to_path_buf()])
}
// -----------------------------------------------------------------------
// getImageBase64 — test-plan 4.1
// -----------------------------------------------------------------------
#[tokio::test]
async fn get_image_base64_png() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.png");
// Minimal valid-looking PNG bytes (magic header)
let png_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
fs::write(&file, &png_bytes).unwrap();
let svc = make_service(dir.path());
let result = svc.get_image_base64(file.to_str().unwrap(), None).await.unwrap();
assert!(
result.starts_with("data:image/png;base64,"),
"expected data:image/png;base64, prefix, got: {}",
&result[..50.min(result.len())]
);
// Verify roundtrip: decode base64 back to original bytes
let encoded_part = result.strip_prefix("data:image/png;base64,").unwrap();
let decoded = base64::engine::general_purpose::STANDARD.decode(encoded_part).unwrap();
assert_eq!(decoded, png_bytes);
}
#[tokio::test]
async fn get_image_base64_jpeg() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("photo.jpg");
let jpeg_bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
fs::write(&file, &jpeg_bytes).unwrap();
let svc = make_service(dir.path());
let result = svc.get_image_base64(file.to_str().unwrap(), None).await.unwrap();
assert!(result.starts_with("data:image/jpeg;base64,"));
}
#[tokio::test]
async fn get_image_base64_svg() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("icon.svg");
let svg_content =
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="40"/></svg>"#;
fs::write(&file, svg_content).unwrap();
let svc = make_service(dir.path());
let result = svc.get_image_base64(file.to_str().unwrap(), None).await.unwrap();
assert!(result.starts_with("data:image/svg+xml;base64,"));
// Verify content roundtrip
let encoded_part = result.strip_prefix("data:image/svg+xml;base64,").unwrap();
let decoded = base64::engine::general_purpose::STANDARD.decode(encoded_part).unwrap();
assert_eq!(String::from_utf8(decoded).unwrap(), svg_content);
}
#[tokio::test]
async fn get_image_base64_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc
.get_image_base64(dir.path().join("missing.png").to_str().unwrap(), None)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn image_base64_with_extra_workspace_root() {
let sandbox = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let file = workspace.path().join("test.png");
let png_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
fs::write(&file, &png_bytes).unwrap();
let svc = make_service(sandbox.path());
let result = svc
.get_image_base64(file.to_str().unwrap(), Some(workspace.path()))
.await;
assert!(result.unwrap().starts_with("data:image/png;base64,"));
}
#[tokio::test]
async fn get_image_base64_gif() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("animation.gif");
let gif_bytes = b"GIF89a\x01\x00\x01\x00\x80\x00\x00";
fs::write(&file, gif_bytes).unwrap();
let svc = make_service(dir.path());
let result = svc.get_image_base64(file.to_str().unwrap(), None).await.unwrap();
assert!(result.starts_with("data:image/gif;base64,"));
}
#[tokio::test]
async fn get_image_base64_path_traversal_rejected() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.get_image_base64("../../etc/passwd", None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_image_base64_outside_sandbox_rejected() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
// /tmp exists but is outside the sandbox (dir.path())
let result = svc.get_image_base64("/etc/hosts", None).await;
assert!(result.is_err());
}
// -----------------------------------------------------------------------
// fetchRemoteImage — test-plan 4.2
// -----------------------------------------------------------------------
#[tokio::test]
async fn fetch_remote_image_disallowed_host_returns_placeholder() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.fetch_remote_image("https://evil.com/image.png").await;
assert!(
result.starts_with("data:image/svg+xml;base64,"),
"expected placeholder SVG, got: {}",
&result[..60.min(result.len())]
);
}
#[tokio::test]
async fn fetch_remote_image_ftp_protocol_returns_placeholder() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.fetch_remote_image("ftp://github.com/image.png").await;
assert!(result.starts_with("data:image/svg+xml;base64,"));
}
#[tokio::test]
async fn fetch_remote_image_invalid_url_returns_placeholder() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.fetch_remote_image("not-a-url").await;
assert!(result.starts_with("data:image/svg+xml;base64,"));
}
#[tokio::test]
async fn fetch_remote_image_file_protocol_returns_placeholder() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.fetch_remote_image("file:///etc/passwd").await;
assert!(result.starts_with("data:image/svg+xml;base64,"));
}
#[tokio::test]
async fn fetch_remote_image_empty_url_returns_placeholder() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.fetch_remote_image("").await;
assert!(result.starts_with("data:image/svg+xml;base64,"));
}
#[tokio::test]
async fn fetch_remote_image_placeholder_contains_valid_svg() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.fetch_remote_image("not-valid").await;
// Verify the placeholder decodes to a valid SVG
let encoded_part = result.strip_prefix("data:image/svg+xml;base64,").unwrap();
let decoded = base64::engine::general_purpose::STANDARD.decode(encoded_part).unwrap();
let svg = String::from_utf8(decoded).unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
#[tokio::test]
async fn fetch_remote_image_data_protocol_returns_placeholder() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
let result = svc.fetch_remote_image("data:text/html,<script>alert(1)</script>").await;
assert!(result.starts_with("data:image/svg+xml;base64,"));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,363 @@
//! Integration tests for ZIP packaging operations (task 7.7).
//!
//! These tests exercise `create_zip` and `cancel_zip` through the
//! `IFileService` trait, covering text content packaging, disk file
//! packaging, mixed entries, cancellation, sandbox validation,
//! and archive verification.
use std::fs;
use std::io::Read;
use std::sync::Arc;
use nomifun_api_types::WebSocketMessage;
use nomifun_file::{FileService, IFileService, ZipEntry};
use nomifun_realtime::EventBroadcaster;
/// No-op broadcaster for tests that don't need event verification.
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
}
fn make_service(root: &std::path::Path) -> FileService {
FileService::new(Arc::new(NoopBroadcaster), vec![root.to_path_buf()])
}
// -----------------------------------------------------------------------
// create_zip — test-plan 5.1
// -----------------------------------------------------------------------
#[tokio::test]
async fn create_zip_text_content() {
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("text.zip");
let svc = make_service(dir.path());
let entries = vec![
ZipEntry::Text {
name: "a.txt".into(),
content: "hello".into(),
},
ZipEntry::Text {
name: "dir/b.txt".into(),
content: "world".into(),
},
];
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await.unwrap();
assert!(result);
// Verify the ZIP can be opened and contains correct data
let file = fs::File::open(&zip_path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
assert_eq!(archive.len(), 2);
{
let mut entry = archive.by_name("a.txt").unwrap();
let mut buf = String::new();
entry.read_to_string(&mut buf).unwrap();
assert_eq!(buf, "hello");
}
{
let mut entry = archive.by_name("dir/b.txt").unwrap();
let mut buf = String::new();
entry.read_to_string(&mut buf).unwrap();
assert_eq!(buf, "world");
}
}
#[tokio::test]
async fn create_zip_disk_files() {
let dir = tempfile::tempdir().unwrap();
let src_a = dir.path().join("src_a.txt");
let src_b = dir.path().join("src_b.bin");
fs::write(&src_a, "file A content").unwrap();
fs::write(&src_b, b"\x00\x01\x02\x03").unwrap();
let zip_path = dir.path().join("disk.zip");
let svc = make_service(dir.path());
let entries = vec![
ZipEntry::Disk {
name: "a.txt".into(),
file_path: src_a.to_string_lossy().into_owned(),
},
ZipEntry::Disk {
name: "b.bin".into(),
file_path: src_b.to_string_lossy().into_owned(),
},
];
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await.unwrap();
assert!(result);
let file = fs::File::open(&zip_path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
assert_eq!(archive.len(), 2);
{
let mut entry = archive.by_name("a.txt").unwrap();
let mut buf = String::new();
entry.read_to_string(&mut buf).unwrap();
assert_eq!(buf, "file A content");
}
{
let mut entry = archive.by_name("b.bin").unwrap();
let mut buf = Vec::new();
entry.read_to_end(&mut buf).unwrap();
assert_eq!(buf, b"\x00\x01\x02\x03");
}
}
#[tokio::test]
async fn create_zip_mixed_content_and_disk() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("real.txt");
fs::write(&src, "real file").unwrap();
let zip_path = dir.path().join("mixed.zip");
let svc = make_service(dir.path());
let entries = vec![
ZipEntry::Text {
name: "virtual.txt".into(),
content: "in-memory".into(),
},
ZipEntry::Disk {
name: "real.txt".into(),
file_path: src.to_string_lossy().into_owned(),
},
];
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await.unwrap();
assert!(result);
let file = fs::File::open(&zip_path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
assert_eq!(archive.len(), 2);
{
let mut entry = archive.by_name("virtual.txt").unwrap();
let mut buf = String::new();
entry.read_to_string(&mut buf).unwrap();
assert_eq!(buf, "in-memory");
}
{
let mut entry = archive.by_name("real.txt").unwrap();
let mut buf = String::new();
entry.read_to_string(&mut buf).unwrap();
assert_eq!(buf, "real file");
}
}
#[tokio::test]
async fn create_zip_with_request_id() {
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("req.zip");
let svc = make_service(dir.path());
let entries = vec![ZipEntry::Text {
name: "data.txt".into(),
content: "test data".into(),
}];
let result = svc
.create_zip(zip_path.to_str().unwrap(), entries, Some("req-123".into()))
.await
.unwrap();
assert!(result);
assert!(zip_path.exists());
}
// -----------------------------------------------------------------------
// cancel_zip — test-plan 5.2
// -----------------------------------------------------------------------
#[tokio::test]
async fn cancel_zip_nonexistent_request() {
let dir = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
// Cancelling a request that doesn't exist returns false
let result = svc.cancel_zip("no-such-id").await;
assert!(!result);
}
#[tokio::test]
async fn cancel_zip_completed_request_returns_false() {
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("done.zip");
let svc = make_service(dir.path());
let entries = vec![ZipEntry::Text {
name: "a.txt".into(),
content: "data".into(),
}];
// Complete the ZIP first
svc.create_zip(zip_path.to_str().unwrap(), entries, Some("req-done".into()))
.await
.unwrap();
// After completion, the token is cleaned up — cancel returns false
let result = svc.cancel_zip("req-done").await;
assert!(!result);
}
// -----------------------------------------------------------------------
// Error cases
// -----------------------------------------------------------------------
#[tokio::test]
async fn create_zip_disk_entry_nonexistent_source() {
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("fail.zip");
let svc = make_service(dir.path());
let entries = vec![ZipEntry::Disk {
name: "missing.txt".into(),
file_path: "/nonexistent/path/file.txt".into(),
}];
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn create_zip_empty_entries_produces_valid_archive() {
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("empty.zip");
let svc = make_service(dir.path());
let result = svc.create_zip(zip_path.to_str().unwrap(), vec![], None).await.unwrap();
assert!(result);
let file = fs::File::open(&zip_path).unwrap();
let archive = zip::ZipArchive::new(file).unwrap();
assert_eq!(archive.len(), 0);
}
// -----------------------------------------------------------------------
// Sandbox validation
// -----------------------------------------------------------------------
#[tokio::test]
async fn create_zip_rejects_output_outside_sandbox() {
let dir = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
let svc = make_service(dir.path());
// Output path is outside the allowed root
let zip_path = other.path().join("escape.zip");
let entries = vec![ZipEntry::Text {
name: "a.txt".into(),
content: "data".into(),
}];
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await;
assert!(result.is_err());
assert!(!zip_path.exists());
}
#[tokio::test]
async fn create_zip_rejects_disk_entry_outside_sandbox() {
let dir = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
let outside_file = other.path().join("secret.txt");
fs::write(&outside_file, "sensitive data").unwrap();
let svc = make_service(dir.path());
let zip_path = dir.path().join("steal.zip");
let entries = vec![ZipEntry::Disk {
name: "stolen.txt".into(),
file_path: outside_file.to_string_lossy().into_owned(),
}];
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await;
assert!(result.is_err());
assert!(!zip_path.exists());
}
#[tokio::test]
async fn create_zip_rejects_nonexistent_disk_entry_in_sandbox() {
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("fail.zip");
let svc = make_service(dir.path());
// Disk entry points to a non-existent file inside the sandbox —
// validate_path rejects it before any ZIP is created.
let entries = vec![ZipEntry::Disk {
name: "missing.txt".into(),
file_path: dir.path().join("no_such.txt").to_string_lossy().into_owned(),
}];
let result = svc.create_zip(zip_path.to_str().unwrap(), entries, None).await;
assert!(result.is_err());
assert!(!zip_path.exists());
}
// -----------------------------------------------------------------------
// cancel_zip — in-progress cancellation (test-plan 5.2)
// -----------------------------------------------------------------------
#[tokio::test]
async fn cancel_zip_in_progress() {
let dir = tempfile::tempdir().unwrap();
// Create many small source files to give time for cancellation
let src_dir = dir.path().join("sources");
fs::create_dir(&src_dir).unwrap();
let entry_count = 500;
let mut entries = Vec::with_capacity(entry_count);
for i in 0..entry_count {
let name = format!("file_{i:04}.txt");
let path = src_dir.join(&name);
fs::write(&path, format!("content of file {i}")).unwrap();
entries.push(ZipEntry::Disk {
name,
file_path: path.to_string_lossy().into_owned(),
});
}
let zip_path = dir.path().join("big.zip");
let svc = Arc::new(make_service(dir.path()));
let svc_cancel = Arc::clone(&svc);
let zip_path_str = zip_path.to_string_lossy().into_owned();
let request_id = "cancel-me".to_owned();
// Spawn ZIP creation in a separate task
let create_handle = tokio::spawn({
let request_id = request_id.clone();
async move { svc.create_zip(&zip_path_str, entries, Some(request_id)).await }
});
// Give a brief moment for the creation to start, then cancel
tokio::task::yield_now().await;
let cancelled = svc_cancel.cancel_zip(&request_id).await;
let result = create_handle.await.unwrap();
// Either the cancel signal was picked up (Ok(false)) or it completed
// before the signal arrived (Ok(true)). The key assertion is that
// cancel_zip returned true (it found the token).
if cancelled {
// cancel_zip found and set the flag
match result {
Ok(false) => {
// Successfully cancelled — partial file should be removed
assert!(
!zip_path.exists(),
"partial ZIP should be cleaned up after cancellation"
);
}
Ok(true) => {
// ZIP completed before cancellation took effect — file exists
assert!(zip_path.exists());
}
Err(e) => panic!("unexpected error: {e}"),
}
} else {
// Token was already cleaned up, meaning create_zip finished first
assert!(result.is_ok());
}
}