Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user