diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e153f1a..a6ffaaa 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2792,6 +2792,7 @@ dependencies = [ "tauri-plugin-webdriver-automation", "tauri-plugin-window-state", "tokio", + "uuid", "zip 2.4.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e19c0bc..ee91bfd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,6 +43,7 @@ flate2 = "1" reqwest = { version = "0.12", features = ["stream"] } futures-util = "0.3" sha2 = "0.10" +uuid = { version = "1", features = ["v4"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/capabilities/backup-selector.json b/src-tauri/capabilities/backup-selector.json new file mode 100644 index 0000000..41f68f2 --- /dev/null +++ b/src-tauri/capabilities/backup-selector.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "backup-selector-capability", + "description": "Capability for the backup target selector window", + "windows": ["backup-selector"], + "permissions": [ + "core:default", + "window-state:default", + "store:default" + ] +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3340d75..0f975fa 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main-capability", "description": "Capability for the main window", - "windows": ["main", "backup-selector"], + "windows": ["main"], "permissions": [ "core:default", "window-state:default", @@ -11,7 +11,6 @@ "core:webview:allow-create-webview-window", "core:webview:allow-set-webview-focus", "dialog:default", - "fs:default", "process:default", "store:default", "updater:default", @@ -26,6 +25,7 @@ { "url": "https://api.papermc.io/**" }, { "url": "https://api.leafmc.one/**" }, { "url": "https://piston-meta.mojang.com/**" }, + { "url": "https://piston-data.mojang.com/**" }, { "url": "https://launchermeta.mojang.com/**" }, { "url": "https://meta.fabricmc.net/**" }, { "url": "https://api.modrinth.com/**" }, @@ -38,21 +38,6 @@ { "url": "https://bin.equinox.io/**" }, { "url": "https://tukuyomil032.github.io/**" } ] - }, - { - "identifier": "fs:scope", - "allow": [{ "path": "$APPDATA/**" }, { "path": "$APPLOCALDATA/**" }, { "path": "$HOME/**" }] - }, - { - "identifier": "fs:scope", - "allow": [ - { "path": "$APPDATA/mc-vector/**" }, - { "path": "$APPLOCALDATA/mc-vector/**" }, - { "path": "$HOME/.mc-vector/**" }, - { "path": "$HOME/mc-vector/**" } - ] - }, - "fs:allow-remove", - "fs:allow-rename" + } ] } diff --git a/src-tauri/src/commands/file_utils.rs b/src-tauri/src/commands/file_utils.rs index 3f60d80..bf64401 100644 --- a/src-tauri/src/commands/file_utils.rs +++ b/src-tauri/src/commands/file_utils.rs @@ -1,6 +1,9 @@ -use std::ffi::OsString; -use std::path::{Component, Path, PathBuf}; -use tauri::{AppHandle, Manager}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager, State}; +use tauri_plugin_dialog::DialogExt; +use uuid::Uuid; #[derive(serde::Serialize)] pub struct FileEntryInfo { @@ -12,307 +15,1205 @@ pub struct FileEntryInfo { pub modified: u64, } -const ALLOWED_APPDATA_SUBDIRS: [&str; 3] = ["servers", "java", "ngrok"]; +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportedManagedFile { + pub server_id: Option, + pub relative_path: String, + pub is_directory: bool, + pub size: u64, +} -fn is_windows_drive_root(path: &str) -> bool { - let bytes = path.as_bytes(); - bytes.len() == 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' +#[derive(Default)] +pub struct ServerImportManager { + pending: Mutex>, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerImportAnalysis { + pub token: String, + pub folder_name: String, + pub detected_version: String, + pub detected_software: String, + pub eula_accepted: bool, + pub has_server_jar: bool, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletedServerImport { + pub server_id: String, + pub relative_path: String, + pub file_count: u64, + pub byte_size: u64, } -fn is_absolute_path(path: &str) -> bool { - if path.starts_with('/') { +/// The only storage roots this command module may access. +/// +/// `Servers` requires a server ID and resolves below `servers/`. +/// The other roots are application-managed shared storage locations. +#[derive(Clone, Copy, Debug, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ManagedRoot { + Servers, + Java, + Ngrok, + Backups, +} + +/// An IPC-safe location. No command in this module accepts a caller-provided +/// absolute filesystem path. +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedPathRequest { + pub root: ManagedRoot, + #[serde(default)] + pub server_id: Option, + pub relative_path: String, +} + +fn is_link_or_reparse_point(metadata: &std::fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { return true; } + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + } + + #[cfg(not(windows))] + false +} + +fn validate_server_id(server_id: &str) -> Result { + let normalized = server_id.trim(); + if normalized.is_empty() { + return Err("Server ID is empty".to_string()); + } + if normalized.len() > 128 { + return Err("Server ID is too long".to_string()); + } + if normalized.chars().any(char::is_control) { + return Err("Server ID contains control characters".to_string()); + } + if matches!(normalized, "." | "..") + || normalized.contains('/') + || normalized.contains('\\') + || normalized.contains(':') + { + return Err("Server ID must not contain path separators".to_string()); + } + + Ok(normalized.to_string()) +} + +fn is_absolute_like(path: &str) -> bool { let bytes = path.as_bytes(); - bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' + path.starts_with('/') + || path.starts_with('\\') + || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') } -fn has_traversal_segment(path: &str) -> bool { - path == ".." || path.starts_with("../") || path.contains("/../") || path.ends_with("/..") +fn validated_relative_path(path: &str) -> Result { + if path.chars().any(char::is_control) { + return Err("Relative path contains control characters".to_string()); + } + if is_absolute_like(path) { + return Err("Absolute paths are not allowed".to_string()); + } + + let mut relative = PathBuf::new(); + for segment in path + .split(['/', '\\']) + .filter(|segment| !segment.is_empty()) + { + if matches!(segment, "." | "..") { + return Err("Path traversal is not allowed".to_string()); + } + relative.push(segment); + } + + Ok(relative) } -fn normalize_path_string(input: &str) -> String { - let mut normalized = String::with_capacity(input.len()); - let mut previous_was_slash = false; - for ch in input.chars() { - let current = if ch == '\\' { '/' } else { ch }; - if current == '/' { - if previous_was_slash { - continue; +pub(crate) fn resolve_managed_request( + app_data_dir: &Path, + request: &ManagedPathRequest, + create_root: bool, +) -> Result { + let root = request.root; + let managed_root = match root { + ManagedRoot::Servers => app_data_dir.join("servers"), + ManagedRoot::Java => app_data_dir.join("java"), + ManagedRoot::Ngrok => app_data_dir.join("ngrok"), + ManagedRoot::Backups => app_data_dir.join("backups"), + }; + + let mut components = Vec::new(); + match root { + ManagedRoot::Servers | ManagedRoot::Backups => { + let server_id = request + .server_id + .as_deref() + .ok_or_else(|| "This managed path requires a server ID".to_string())?; + let server_id = server_id.trim(); + if server_id.is_empty() { + return Err("Server ID is empty".to_string()); + } + if server_id.len() > 128 { + return Err("Server ID is too long".to_string()); + } + if server_id.chars().any(char::is_control) + || server_id == "." + || server_id == ".." + || server_id.contains('/') + || server_id.contains('\\') + || server_id.contains(':') + { + return Err("Server ID must be a single safe path component".to_string()); + } + components.push(server_id.to_string()); + } + _ => { + if request.server_id.is_some() { + return Err("Only server paths may include a server ID".to_string()); } - previous_was_slash = true; - } else { - previous_was_slash = false; } - normalized.push(current); } - if normalized.len() > 1 && normalized.ends_with('/') && !is_windows_drive_root(&normalized) { - normalized.pop(); + let relative_path = request.relative_path.trim(); + if relative_path.chars().any(char::is_control) { + return Err("Relative path contains control characters".to_string()); + } + if is_absolute_like(relative_path) { + return Err("Absolute paths are not allowed".to_string()); + } + for segment in relative_path + .split(['/', '\\']) + .filter(|segment| !segment.is_empty()) + { + if segment.is_empty() + || segment == "." + || segment == ".." + || segment.contains('/') + || segment.contains('\\') + || segment.chars().any(char::is_control) + { + return Err("Managed path contains an unsafe component".to_string()); + } + components.push(segment.to_string()); } - normalized -} + if create_root { + std::fs::create_dir_all(&managed_root) + .map_err(|error| format!("Failed to prepare managed root: {error}"))?; + } -fn normalize_managed_input_path(app: &AppHandle, path: &str) -> Result { - let normalized = normalize_path_string(path.trim()); - if normalized.is_empty() || normalized.contains('\0') { - return Err("Invalid path".to_string()); + let metadata = std::fs::symlink_metadata(&managed_root) + .map_err(|error| format!("Failed to inspect managed root: {error}"))?; + if is_link_or_reparse_point(&metadata) { + return Err("Refusing to access a symbolic-link or reparse-point managed root".to_string()); } - if has_traversal_segment(&normalized) { - return Err("Path traversal is not allowed".to_string()); + if !metadata.is_dir() { + return Err("Managed root is not a directory".to_string()); } - if is_absolute_path(&normalized) { - return Ok(normalized); + let canonical_root = std::fs::canonicalize(&managed_root) + .map_err(|error| format!("Failed to resolve managed root: {error}"))?; + + let mut current = canonical_root.clone(); + for (index, component) in components.iter().enumerate() { + let is_final = index + 1 == components.len(); + let entry = std::fs::read_dir(¤t) + .map_err(|error| format!("Failed to inspect managed path parent: {error}"))? + .find_map(|entry| { + let entry = entry.ok()?; + (entry.file_name() == component.as_str()).then_some(entry) + }); + + if let Some(entry) = entry { + let metadata = entry + .metadata() + .map_err(|error| format!("Failed to inspect managed path component: {error}"))?; + if is_link_or_reparse_point(&metadata) { + return Err("Managed path contains a symbolic link or reparse point".to_string()); + } + if !is_final && !metadata.is_dir() { + return Err("Managed path parent is not a directory".to_string()); + } + current = entry.path(); + continue; + } + + if !is_final { + return Err("Managed path parent does not exist".to_string()); + } + + // A missing final component is returned for the caller to create. The + // parent came from a trusted directory entry (or the fixed root), and + // the component was checked above as one normal path component. + let candidate = current.join(component); + if !candidate.starts_with(&canonical_root) { + return Err("Path is outside the managed root".to_string()); + } + return Ok(candidate); } - let mut relative_path = normalized.as_str(); - if let Some(rest) = relative_path.strip_prefix('.') { - if rest.starts_with('/') { - relative_path = rest.trim_start_matches('/'); + if !current.starts_with(&canonical_root) { + return Err("Path is outside the managed root".to_string()); + } + Ok(current) +} + +fn app_data_dir(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map_err(|_| "Failed to resolve app data directory".to_string()) +} + +const MAX_IMPORT_TEXT_BYTES: u64 = 1024 * 1024; + +fn read_import_text(path: &Path) -> Option { + let metadata = std::fs::symlink_metadata(path).ok()?; + if is_link_or_reparse_point(&metadata) + || !metadata.is_file() + || metadata.len() > MAX_IMPORT_TEXT_BYTES + { + return None; + } + std::fs::read_to_string(path).ok() +} + +fn detect_import_software(jar_name: &str) -> &'static str { + [ + ("paper", "Paper"), + ("purpur", "Purpur"), + ("spigot", "Spigot"), + ("craftbukkit", "CraftBukkit"), + ("neoforge", "NeoForge"), + ("fabric", "Fabric"), + ("forge", "Forge"), + ("velocity", "Velocity"), + ("waterfall", "Waterfall"), + ("vanilla", "Vanilla"), + ("minecraft_server", "Vanilla"), + ] + .iter() + .find(|(pattern, _)| jar_name.to_ascii_lowercase().contains(pattern)) + .map(|(_, software)| *software) + .unwrap_or("Paper") +} + +fn detect_import_version(value: &str) -> String { + let bytes = value.as_bytes(); + for start in 0..bytes.len().saturating_sub(2) { + if bytes[start] != b'1' || bytes[start + 1] != b'.' { + continue; + } + let mut end = start + 2; + while end < bytes.len() && bytes[end].is_ascii_digit() { + end += 1; + } + if end > start + 2 { + if end < bytes.len() && bytes[end] == b'.' { + let second_start = end + 1; + let mut second_end = second_start; + while second_end < bytes.len() && bytes[second_end].is_ascii_digit() { + second_end += 1; + } + if second_end > second_start { + end = second_end; + } + } + return value[start..end].to_string(); } } - relative_path = relative_path.trim_start_matches('/'); + String::new() +} - if relative_path.is_empty() { - return Err("Invalid path".to_string()); +fn analyze_import_folder(source: &Path, token: String) -> Result { + let source_metadata = std::fs::symlink_metadata(source) + .map_err(|error| format!("Failed to inspect selected server folder: {error}"))?; + if is_link_or_reparse_point(&source_metadata) || !source_metadata.is_dir() { + return Err("Selected server folder must be a real directory".to_string()); } - if has_traversal_segment(relative_path) { - return Err("Path traversal is not allowed".to_string()); + + let folder_name = source + .file_name() + .filter(|name| !name.is_empty()) + .map(|name| name.to_string_lossy().to_string()) + .ok_or_else(|| "Selected server folder has no usable name".to_string())?; + + let mut jar_name = None; + for entry in std::fs::read_dir(source) + .map_err(|error| format!("Failed to read selected server folder: {error}"))? + { + let entry = entry.map_err(|error| format!("Failed to read selected entry: {error}"))?; + let metadata = entry + .metadata() + .map_err(|error| format!("Failed to inspect selected entry: {error}"))?; + if is_link_or_reparse_point(&metadata) { + return Err( + "Selected server folder contains a symbolic link or reparse point".to_string(), + ); + } + let name = entry.file_name().to_string_lossy().to_string(); + if metadata.is_file() + && name.to_ascii_lowercase().ends_with(".jar") + && name != "bundler.jar" + { + jar_name = Some(name); + } } - let managed_relative = if ALLOWED_APPDATA_SUBDIRS.iter().any(|segment| { - relative_path == *segment || relative_path.starts_with(&format!("{}/", segment)) - }) { - relative_path.to_string() - } else { - format!("servers/{}", relative_path) - }; + let mut detected_version = jar_name + .as_deref() + .map(detect_import_version) + .unwrap_or_default(); + if detected_version.is_empty() { + if let Some(content) = read_import_text(&source.join("server.properties")) { + detected_version = detect_import_version(&content); + } + } - let app_data_dir = app - .path() - .app_data_dir() - .map_err(|_| "Failed to resolve app data directory".to_string())?; + let eula_accepted = read_import_text(&source.join("eula.txt")) + .map(|content| { + content.lines().any(|line| { + let mut fields = line.splitn(2, '='); + matches!(fields.next().map(str::trim), Some("eula")) + && matches!(fields.next().map(str::trim), Some(value) if value.eq_ignore_ascii_case("true")) + }) + }) + .unwrap_or(false); + + Ok(ServerImportAnalysis { + token, + folder_name, + detected_version, + detected_software: jar_name + .as_deref() + .map(detect_import_software) + .unwrap_or("Paper") + .to_string(), + eula_accepted, + has_server_jar: jar_name.is_some(), + }) +} - Ok(normalize_path_string(&format!( - "{}/{}", - app_data_dir.to_string_lossy(), - managed_relative - ))) +/// Selects and analyzes an external server folder without exposing its path to +/// the renderer. The opaque token is the only value the renderer can retain. +#[tauri::command] +pub async fn pick_server_import( + app: AppHandle, + state: State<'_, ServerImportManager>, +) -> Result, String> { + let selected = tokio::task::spawn_blocking({ + let app = app.clone(); + move || app.dialog().file().blocking_pick_folder() + }) + .await + .map_err(|error| format!("Folder picker task failed: {error}"))?; + let Some(selected) = selected else { + return Ok(None); + }; + let source = selected + .into_path() + .map_err(|error| format!("Failed to resolve selected server folder: {error}"))?; + let token = Uuid::new_v4().to_string(); + let analysis = tokio::task::spawn_blocking({ + let source_for_analysis = source.clone(); + let token = token.clone(); + move || analyze_import_folder(&source_for_analysis, token) + }) + .await + .map_err(|error| format!("Server folder analysis task failed: {error}"))??; + + state + .pending + .lock() + .map_err(|_| "Server import state is unavailable".to_string())? + .insert(analysis.token.clone(), source); + Ok(Some(analysis)) } -fn canonicalize_with_existing_ancestor(path: &Path) -> Result { - if path.exists() { - return std::fs::canonicalize(path).map_err(|e| format!("Failed to resolve path: {}", e)); +/// Copies the selected server folder's contents into an already-created +/// managed server directory. Tokens are one-shot and source paths remain Rust-only. +#[tauri::command] +pub async fn complete_server_import( + app: AppHandle, + state: State<'_, ServerImportManager>, + token: String, + server_id: String, +) -> Result { + let source = state + .pending + .lock() + .map_err(|_| "Server import state is unavailable".to_string())? + .remove(token.trim()) + .ok_or_else(|| "Server import session is missing or expired".to_string())?; + + let app_data_dir = app_data_dir(&app)?; + let destination_request = ManagedPathRequest { + root: ManagedRoot::Servers, + server_id: Some(server_id.clone()), + relative_path: String::new(), + }; + let destination = resolve_managed_request(&app_data_dir, &destination_request, false)?; + let managed_root = std::fs::canonicalize(app_data_dir.join("servers")) + .map_err(|error| format!("Failed to resolve managed servers root: {error}"))?; + let destination_metadata = std::fs::symlink_metadata(&destination) + .map_err(|error| format!("Failed to inspect managed import destination: {error}"))?; + if is_link_or_reparse_point(&destination_metadata) || !destination_metadata.is_dir() { + return Err("Managed import destination must be a real directory".to_string()); + } + if std::fs::read_dir(&destination) + .map_err(|error| format!("Failed to inspect managed import destination: {error}"))? + .next() + .is_some() + { + return Err("Managed import destination must be empty".to_string()); } - let mut existing_ancestor = path.to_path_buf(); - let mut missing_segments: Vec = Vec::new(); + let source_metadata = std::fs::symlink_metadata(&source) + .map_err(|error| format!("Failed to inspect selected server folder: {error}"))?; + if is_link_or_reparse_point(&source_metadata) || !source_metadata.is_dir() { + return Err("Selected server folder is no longer safe to import".to_string()); + } - while !existing_ancestor.exists() { - let segment = existing_ancestor - .file_name() - .ok_or_else(|| "Path has no existing parent".to_string())?; - missing_segments.push(segment.to_os_string()); - existing_ancestor = existing_ancestor - .parent() - .ok_or_else(|| "Path has no existing parent".to_string())? - .to_path_buf(); + let mut entry_count = 0; + let mut byte_size: u64 = 0; + for entry in std::fs::read_dir(&source) + .map_err(|error| format!("Failed to read selected server folder: {error}"))? + { + let entry = entry.map_err(|error| format!("Failed to read selected entry: {error}"))?; + let destination_entry = destination.join(entry.file_name()); + let (_, size) = copy_external_entry(&entry.path(), &destination_entry, &managed_root)?; + entry_count += 1; + byte_size = byte_size.saturating_add(size); } - let mut canonical = std::fs::canonicalize(&existing_ancestor) - .map_err(|e| format!("Failed to resolve path: {}", e))?; - for segment in missing_segments.iter().rev() { - canonical.push(segment); + Ok(CompletedServerImport { + server_id, + relative_path: String::new(), + file_count: entry_count, + byte_size, + }) +} + +#[tauri::command] +pub async fn cancel_server_import( + state: State<'_, ServerImportManager>, + token: String, +) -> Result<(), String> { + state + .pending + .lock() + .map_err(|_| "Server import state is unavailable".to_string())? + .remove(token.trim()); + Ok(()) +} + +#[tauri::command] +pub async fn resolve_managed_path( + app: AppHandle, + request: ManagedPathRequest, +) -> Result { + let app_data_dir = app_data_dir(&app)?; + let resolved = resolve_managed_request(&app_data_dir, &request, true)?; + Ok(resolved.to_string_lossy().to_string()) +} + +#[tauri::command] +pub async fn write_managed_text_file( + app: AppHandle, + request: ManagedPathRequest, + content: String, +) -> Result<(), String> { + let app_data_dir = app_data_dir(&app)?; + let resolved = resolve_managed_request(&app_data_dir, &request, true)?; + let managed_root = match request.root { + ManagedRoot::Servers => app_data_dir.join("servers"), + ManagedRoot::Java => app_data_dir.join("java"), + ManagedRoot::Ngrok => app_data_dir.join("ngrok"), + ManagedRoot::Backups => app_data_dir.join("backups"), + }; + if resolved == managed_root { + return Err("Managed path must identify a file".to_string()); } - Ok(canonical) + + std::fs::write(&resolved, content).map_err(|error| format!("Failed to write file: {error}")) } -fn is_within_root(target_path: &Path, root_path: &Path) -> bool { - target_path == root_path || target_path.starts_with(root_path) +struct ResolvedPickerEntry { + path: PathBuf, + metadata: std::fs::Metadata, } -fn validate_managed_server_dir_for_delete( - servers_root: &Path, - server_path: &str, -) -> Result { - let normalized_input = normalize_path_string(server_path.trim()); - if normalized_input.is_empty() || normalized_input.contains('\0') { - return Err("Invalid server path".to_string()); +fn resolve_picker_entry(source: &Path) -> Result { + if !source.is_absolute() + || source.components().any(|component| { + matches!( + component, + std::path::Component::CurDir | std::path::Component::ParentDir + ) + }) + { + return Err("Selected source must be an absolute, normalized path".to_string()); } - if has_traversal_segment(&normalized_input) { - return Err("Path traversal is not allowed".to_string()); + let source_name = source + .file_name() + .ok_or_else(|| "Selected source has no file name".to_string())?; + if source_name.is_empty() + || source_name == "." + || source_name == ".." + || source_name.to_string_lossy().chars().any(char::is_control) + { + return Err("Selected source has an unsafe file name".to_string()); + } + let source_parent = source + .parent() + .ok_or_else(|| "Selected source has no parent".to_string())?; + let canonical_parent = std::fs::canonicalize(source_parent) + .map_err(|error| format!("Failed to resolve selected source parent: {error}"))?; + if canonical_parent != source_parent || !canonical_parent.is_absolute() { + return Err("Selected source parent must not be a symbolic link".to_string()); } - let target_path = PathBuf::from(&normalized_input); - if !target_path.is_absolute() { - return Err("Server path must be absolute".to_string()); + let entry = std::fs::read_dir(&canonical_parent) + .map_err(|error| format!("Failed to inspect selected source parent: {error}"))? + .find_map(|entry| { + let entry = entry.ok()?; + (entry.file_name() == source_name).then_some(entry) + }) + .ok_or_else(|| "Selected source no longer exists".to_string())?; + let file_type = entry + .file_type() + .map_err(|error| format!("Failed to inspect selected source type: {error}"))?; + let metadata = entry + .metadata() + .map_err(|error| format!("Failed to inspect selected source: {error}"))?; + if file_type.is_symlink() { + return Err("Selected source must not be a symbolic link or reparse point".to_string()); + } + if is_link_or_reparse_point(&metadata) { + return Err("Selected source must not be a symbolic link or reparse point".to_string()); } - if target_path - .components() - .any(|component| matches!(component, Component::ParentDir | Component::CurDir)) + if !metadata.is_file() && !metadata.is_dir() { + return Err("Selected source must be a file or directory".to_string()); + } + Ok(ResolvedPickerEntry { + path: entry.path(), + metadata, + }) +} + +fn resolve_managed_destination(destination: &Path, managed_root: &Path) -> Result { + if !destination.is_absolute() || !destination.starts_with(managed_root) { + return Err("Import destination is outside the managed root".to_string()); + } + let destination_parent = destination + .parent() + .ok_or_else(|| "Import destination has no parent".to_string())?; + let canonical_parent = std::fs::canonicalize(destination_parent) + .map_err(|error| format!("Failed to resolve import destination parent: {error}"))?; + if canonical_parent != destination_parent + || !canonical_parent.is_absolute() + || !canonical_parent.starts_with(managed_root) + { + return Err("Import destination parent is outside the managed root".to_string()); + } + let destination_name = destination + .file_name() + .ok_or_else(|| "Import destination has no file name".to_string())?; + if destination_name.is_empty() + || destination_name == "." + || destination_name == ".." + || destination_name + .to_string_lossy() + .chars() + .any(char::is_control) + { + return Err("Import destination has an unsafe file name".to_string()); + } + if std::fs::read_dir(&canonical_parent) + .map_err(|error| format!("Failed to inspect import destination parent: {error}"))? + .find_map(|entry| { + let entry = entry.ok()?; + (entry.file_name() == destination_name).then_some(entry) + }) + .is_some() { - return Err("Path traversal is not allowed".to_string()); + return Err(format!( + "An item with the same name already exists: {}", + destination.display() + )); } + let resolved = canonical_parent.join(destination_name); + if !resolved.starts_with(managed_root) { + return Err("Import destination is outside the managed root".to_string()); + } + Ok(resolved) +} + +fn copy_external_entry( + source: &Path, + destination: &Path, + managed_root: &Path, +) -> Result<(bool, u64), String> { + let source = resolve_picker_entry(source)?; + let destination = resolve_managed_destination(destination, managed_root)?; + copy_external_entry_resolved(source.path, source.metadata, destination, managed_root) +} - if target_path.parent() != Some(servers_root) { - return Err("Only direct managed server folders can be deleted".to_string()); +fn copy_external_entry_resolved( + source: PathBuf, + metadata: std::fs::Metadata, + destination: PathBuf, + managed_root: &Path, +) -> Result<(bool, u64), String> { + let canonical_source = source + .canonicalize() + .map_err(|error| format!("Failed to resolve selected source: {error}"))?; + if !canonical_source.is_absolute() { + return Err("Selected source resolved to a non-absolute path".to_string()); } - Ok(target_path) + if metadata.is_dir() { + std::fs::create_dir(&destination) + .map_err(|error| format!("Failed to create imported directory: {error}"))?; + for entry in std::fs::read_dir(&canonical_source) + .map_err(|error| format!("Failed to read selected directory: {error}"))? + { + let entry = entry.map_err(|error| format!("Failed to read selected entry: {error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("Failed to inspect selected entry type: {error}"))?; + if file_type.is_symlink() { + return Err( + "Selected source must not contain a symbolic link or reparse point".to_string(), + ); + } + let child_metadata = entry + .metadata() + .map_err(|error| format!("Failed to inspect selected entry: {error}"))?; + if is_link_or_reparse_point(&child_metadata) { + return Err( + "Selected source must not contain a symbolic link or reparse point".to_string(), + ); + } + let name = entry.file_name(); + let child_destination = + resolve_managed_destination(&destination.join(&name), managed_root)?; + copy_external_entry_resolved( + entry.path(), + child_metadata, + child_destination, + managed_root, + )?; + } + Ok((true, 0)) + } else { + std::fs::copy(&canonical_source, &destination) + .map_err(|error| format!("Failed to copy selected file: {error}"))?; + let destination_metadata = std::fs::symlink_metadata(&destination) + .map_err(|error| format!("Failed to verify imported file: {error}"))?; + if is_link_or_reparse_point(&destination_metadata) || !destination_metadata.is_file() { + let _ = std::fs::remove_file(&destination); + return Err("Imported file is not a regular file".to_string()); + } + Ok((false, destination_metadata.len())) + } } +/// Opens the native picker and copies the user-selected entries into a +/// managed directory. Source paths never cross the renderer IPC boundary. #[tauri::command] -pub async fn resolve_managed_path(app: AppHandle, path: String) -> Result { - let normalized_input = normalize_managed_input_path(&app, &path)?; - let target_path = PathBuf::from(&normalized_input); - if !target_path.is_absolute() { - return Err("Path must be absolute".to_string()); - } - if target_path - .components() - .any(|component| matches!(component, Component::ParentDir | Component::CurDir)) - { - return Err("Path traversal is not allowed".to_string()); +pub async fn import_managed_files( + app: AppHandle, + request: ManagedPathRequest, +) -> Result, String> { + let app_data_dir = app_data_dir(&app)?; + let destination = resolve_managed_request(&app_data_dir, &request, true)?; + let managed_root = match request.root { + ManagedRoot::Servers => app_data_dir.join("servers"), + ManagedRoot::Java => app_data_dir.join("java"), + ManagedRoot::Ngrok => app_data_dir.join("ngrok"), + ManagedRoot::Backups => app_data_dir.join("backups"), + }; + let managed_root = std::fs::canonicalize(&managed_root) + .map_err(|error| format!("Failed to resolve import managed root: {error}"))?; + let destination_metadata = std::fs::symlink_metadata(&destination) + .map_err(|error| format!("Failed to inspect import destination: {error}"))?; + if is_link_or_reparse_point(&destination_metadata) || !destination_metadata.is_dir() { + return Err("Import destination must be a managed directory".to_string()); } - let app_data_dir = app - .path() - .app_data_dir() - .map_err(|_| "Failed to resolve app data directory".to_string())?; - std::fs::create_dir_all(&app_data_dir) - .map_err(|e| format!("Failed to prepare app data directory: {}", e))?; + let selected = tokio::task::spawn_blocking({ + let app = app.clone(); + move || app.dialog().file().blocking_pick_files() + }) + .await + .map_err(|error| format!("File picker task failed: {error}"))?; + let Some(selected) = selected else { + return Ok(Vec::new()); + }; - let canonical_app_data = canonicalize_with_existing_ancestor(&app_data_dir)?; - let canonical_target = canonicalize_with_existing_ancestor(&target_path)?; + tokio::task::spawn_blocking(move || { + let mut imported = Vec::with_capacity(selected.len()); + for selected_path in selected { + let source = selected_path + .into_path() + .map_err(|error| format!("Failed to resolve selected source: {error}"))?; + let name = source + .file_name() + .ok_or_else(|| "Selected source has no file name".to_string())?; + let destination_path = destination.join(name); + let (is_directory, size) = + copy_external_entry(&source, &destination_path, &managed_root)?; + let name = name.to_string_lossy().to_string(); + let relative_path = if request.relative_path.is_empty() { + name + } else { + format!("{}/{}", request.relative_path, name) + }; + imported.push(ImportedManagedFile { + server_id: request.server_id.clone(), + relative_path, + is_directory, + size, + }); + } + Ok(imported) + }) + .await + .map_err(|error| format!("Import task failed: {error}"))? +} - let is_allowed = ALLOWED_APPDATA_SUBDIRS.iter().any(|segment| { - let root_path = canonical_app_data.join(segment); - is_within_root(&canonical_target, &root_path) - }); +#[tauri::command] +pub async fn export_text_file( + app: AppHandle, + content: String, + suggested_name: String, +) -> Result { + let suggested_name = suggested_name + .trim() + .chars() + .filter(|character| !character.is_control()) + .collect::(); + let selected = tokio::task::spawn_blocking({ + let app = app.clone(); + move || { + app.dialog() + .file() + .set_file_name(suggested_name) + .blocking_save_file() + } + }) + .await + .map_err(|error| format!("Save picker task failed: {error}"))?; + let Some(selected) = selected else { + return Ok(false); + }; + let path = selected + .into_path() + .map_err(|error| format!("Failed to resolve save destination: {error}"))?; + std::fs::write(path, content) + .map_err(|error| format!("Failed to export text file: {error}"))?; + Ok(true) +} - if !is_allowed { - return Err("Path is outside allowed scope".to_string()); +fn reject_managed_root_target( + app_data_dir: &Path, + request: &ManagedPathRequest, + target: &Path, +) -> Result<(), String> { + let root = match request.root { + ManagedRoot::Servers => app_data_dir.join("servers"), + ManagedRoot::Java => app_data_dir.join("java"), + ManagedRoot::Ngrok => app_data_dir.join("ngrok"), + ManagedRoot::Backups => app_data_dir.join("backups"), + }; + if target == root { + return Err("Managed path must identify a child entry".to_string()); } + Ok(()) +} - Ok(normalize_path_string(&canonical_target.to_string_lossy())) +#[tauri::command] +pub async fn create_managed_directory( + app: AppHandle, + request: ManagedPathRequest, +) -> Result<(), String> { + let app_data_dir = app_data_dir(&app)?; + let target = resolve_managed_request(&app_data_dir, &request, true)?; + reject_managed_root_target(&app_data_dir, &request, &target)?; + tokio::fs::create_dir_all(&target) + .await + .map_err(|error| format!("Failed to create managed directory: {error}")) } #[tauri::command] -pub async fn write_managed_text_file( +pub async fn delete_managed_path( app: AppHandle, - path: String, - content: String, + request: ManagedPathRequest, ) -> Result<(), String> { - let resolved = resolve_managed_path(app, path).await?; - std::fs::write(&resolved, content).map_err(|e| format!("Failed to write file: {}", e)) + let app_data_dir = app_data_dir(&app)?; + let target = resolve_managed_request(&app_data_dir, &request, false)?; + reject_managed_root_target(&app_data_dir, &request, &target)?; + let metadata = std::fs::symlink_metadata(&target) + .map_err(|error| format!("Failed to inspect managed path: {error}"))?; + if is_link_or_reparse_point(&metadata) { + return Err("Refusing to delete a symbolic link or reparse point".to_string()); + } + if metadata.is_dir() { + tokio::fs::remove_dir_all(&target) + .await + .map_err(|error| format!("Failed to delete managed directory: {error}")) + } else { + tokio::fs::remove_file(&target) + .await + .map_err(|error| format!("Failed to delete managed file: {error}")) + } } #[tauri::command] -pub async fn read_managed_text_file(app: AppHandle, path: String) -> Result { - let resolved = resolve_managed_path(app, path).await?; +pub async fn move_managed_path( + app: AppHandle, + from: ManagedPathRequest, + to: ManagedPathRequest, +) -> Result<(), String> { + let app_data_dir = app_data_dir(&app)?; + let source = resolve_managed_request(&app_data_dir, &from, false)?; + let destination = resolve_managed_request(&app_data_dir, &to, true)?; + reject_managed_root_target(&app_data_dir, &from, &source)?; + reject_managed_root_target(&app_data_dir, &to, &destination)?; + + let source_metadata = std::fs::symlink_metadata(&source) + .map_err(|error| format!("Failed to inspect managed source: {error}"))?; + if is_link_or_reparse_point(&source_metadata) { + return Err("Refusing to move a symbolic link or reparse point".to_string()); + } + if let Ok(destination_metadata) = std::fs::symlink_metadata(&destination) { + if is_link_or_reparse_point(&destination_metadata) { + return Err("Refusing to replace a symbolic link or reparse point".to_string()); + } + } + + tokio::fs::rename(&source, &destination) + .await + .map_err(|error| format!("Failed to move managed path: {error}")) +} + +#[tauri::command] +pub async fn read_managed_text_file( + app: AppHandle, + request: ManagedPathRequest, +) -> Result { + let app_data_dir = app_data_dir(&app)?; + let resolved = resolve_managed_request(&app_data_dir, &request, false)?; tokio::fs::read_to_string(&resolved) .await - .map_err(|e| format!("Failed to read file: {}", e)) + .map_err(|error| format!("Failed to read file: {error}")) } #[tauri::command] -pub async fn delete_managed_server_dir(app: AppHandle, server_path: String) -> Result<(), String> { - let app_data_dir = app - .path() - .app_data_dir() - .map_err(|_| "Failed to resolve app data directory".to_string())?; - let servers_root = app_data_dir.join("servers"); - let requested_target = validate_managed_server_dir_for_delete(&servers_root, &server_path)?; - - let servers_root_metadata = std::fs::symlink_metadata(&servers_root) - .map_err(|e| format!("Failed to inspect managed servers root: {}", e))?; - if servers_root_metadata.file_type().is_symlink() { - return Err("Refusing to delete through a symbolic-link managed root".to_string()); - } - let canonical_servers_root = std::fs::canonicalize(&servers_root) - .map_err(|e| format!("Failed to resolve managed servers root: {}", e))?; - - // Resolve the requested directory by enumerating the already trusted - // managed root. The user-provided path is used only for an exact match; - // filesystem operations below use the path returned by read_dir. - let target = std::fs::read_dir(&servers_root) - .map_err(|e| format!("Failed to inspect managed server folders: {}", e))? - .map(|entry| entry.map(|entry| entry.path())) - .find_map(|entry| match entry { - Ok(entry_path) if entry_path == requested_target => Some(Ok(entry_path)), - Ok(_) => None, - Err(error) => Some(Err(error)), - }) - .transpose() - .map_err(|e| format!("Failed to inspect managed server folder: {}", e))? - .ok_or_else(|| "Server folder is not a direct managed server folder".to_string())?; +pub async fn delete_managed_server_dir(app: AppHandle, server_id: String) -> Result<(), String> { + let server_id = validate_server_id(&server_id)?; + let app_data_dir = app_data_dir(&app)?; + let request = ManagedPathRequest { + root: ManagedRoot::Servers, + server_id: Some(server_id), + relative_path: String::new(), + }; + let target = resolve_managed_request(&app_data_dir, &request, false)?; + let servers_root = std::fs::canonicalize(app_data_dir.join("servers")) + .map_err(|error| format!("Failed to resolve managed servers root: {error}"))?; + + if target == servers_root || target.parent() != Some(servers_root.as_path()) { + return Err("Server folder is outside the managed servers root".to_string()); + } - let target_metadata = std::fs::symlink_metadata(&target) - .map_err(|e| format!("Failed to inspect server folder: {}", e))?; - if target_metadata.file_type().is_symlink() { - return Err("Refusing to delete a symbolic link".to_string()); + let metadata = std::fs::symlink_metadata(&target) + .map_err(|error| format!("Failed to inspect server folder: {error}"))?; + if is_link_or_reparse_point(&metadata) { + return Err("Refusing to delete a symbolic link or reparse point".to_string()); } - if !target_metadata.is_dir() { + if !metadata.is_dir() { return Err("Server path is not a directory".to_string()); } - let canonical_target = std::fs::canonicalize(&target) - .map_err(|e| format!("Failed to resolve server folder: {}", e))?; - if canonical_target == canonical_servers_root - || canonical_target.parent() != Some(canonical_servers_root.as_path()) + tokio::fs::remove_dir_all(&target) + .await + .map_err(|error| format!("Failed to delete managed server folder: {error}")) +} + +fn resolve_managed_copy_path( + path: &Path, + managed_root: &Path, + allow_missing_final: bool, +) -> Result { + if !path.is_absolute() || !managed_root.is_absolute() || !path.starts_with(managed_root) { + return Err("Managed copy path is outside the managed root".to_string()); + } + + let relative = path + .strip_prefix(managed_root) + .map_err(|_| "Managed copy path is outside the managed root".to_string())?; + let components = relative.components().collect::>(); + if components + .iter() + .any(|component| !matches!(component, std::path::Component::Normal(_))) { - return Err("Server folder is outside the managed servers root".to_string()); + return Err("Managed copy path contains an unsafe component".to_string()); } - tokio::fs::remove_dir_all(&target) - .await - .map_err(|e| format!("Failed to delete managed server folder: {}", e)) + let canonical_root = std::fs::canonicalize(managed_root) + .map_err(|error| format!("Failed to resolve managed copy root: {error}"))?; + let mut current = canonical_root.clone(); + for (index, component) in components.iter().enumerate() { + let is_final = index + 1 == components.len(); + let entry = std::fs::read_dir(¤t) + .map_err(|error| format!("Failed to inspect managed copy parent: {error}"))? + .find_map(|entry| { + let entry = entry.ok()?; + (entry.file_name() == component.as_os_str()).then_some(entry) + }); + + if let Some(entry) = entry { + let metadata = entry + .metadata() + .map_err(|error| format!("Failed to inspect managed copy entry: {error}"))?; + if is_link_or_reparse_point(&metadata) { + return Err( + "Managed copy path contains a symbolic link or reparse point".to_string(), + ); + } + if !is_final && !metadata.is_dir() { + return Err("Managed copy path parent is not a directory".to_string()); + } + current = entry.path(); + continue; + } + + if !is_final || !allow_missing_final { + return Err("Managed copy path does not exist".to_string()); + } + let candidate = current.join(component.as_os_str()); + if !candidate.starts_with(&canonical_root) { + return Err("Managed copy path is outside the managed root".to_string()); + } + return Ok(candidate); + } + + if !current.starts_with(&canonical_root) { + return Err("Managed copy path is outside the managed root".to_string()); + } + Ok(current) +} + +fn copy_managed_tree(source: &Path, destination: &Path, managed_root: &Path) -> Result<(), String> { + let source = resolve_managed_copy_path(source, managed_root, false)?; + let destination = resolve_managed_copy_path(destination, managed_root, true)?; + copy_managed_tree_resolved(source, destination, managed_root) +} + +fn copy_managed_tree_resolved( + source: PathBuf, + destination: PathBuf, + managed_root: &Path, +) -> Result<(), String> { + let metadata = std::fs::symlink_metadata(&source) + .map_err(|error| format!("Failed to inspect source entry: {error}"))?; + if is_link_or_reparse_point(&metadata) { + return Err("Managed source must not contain a symbolic link or reparse point".to_string()); + } + if metadata.is_dir() { + std::fs::create_dir(&destination) + .map_err(|error| format!("Failed to create copied directory: {error}"))?; + for entry in std::fs::read_dir(&source) + .map_err(|error| format!("Failed to read source directory: {error}"))? + { + let entry = entry.map_err(|error| format!("Failed to read source entry: {error}"))?; + let child_name = entry.file_name(); + let child_destination = + resolve_managed_copy_path(&destination.join(&child_name), managed_root, true)?; + let child_source = + resolve_managed_copy_path(&source.join(&child_name), managed_root, false)?; + copy_managed_tree_resolved(child_source, child_destination, managed_root)?; + } + } else if metadata.is_file() { + std::fs::copy(&source, &destination) + .map_err(|error| format!("Failed to copy managed file: {error}"))?; + } else { + return Err("Managed source must be a regular file or directory".to_string()); + } + Ok(()) +} + +#[tauri::command] +pub async fn clone_managed_server( + app: AppHandle, + source_server_id: String, + destination_server_id: String, +) -> Result<(), String> { + let app_data_dir = app_data_dir(&app)?; + let source_request = ManagedPathRequest { + root: ManagedRoot::Servers, + server_id: Some(source_server_id), + relative_path: String::new(), + }; + let destination_request = ManagedPathRequest { + root: ManagedRoot::Servers, + server_id: Some(destination_server_id), + relative_path: String::new(), + }; + let source = resolve_managed_request(&app_data_dir, &source_request, false)?; + let destination = resolve_managed_request(&app_data_dir, &destination_request, true)?; + let managed_root = std::fs::canonicalize(app_data_dir.join("servers")) + .map_err(|error| format!("Failed to resolve managed servers root: {error}"))?; + let source_metadata = std::fs::symlink_metadata(&source) + .map_err(|error| format!("Failed to inspect source server: {error}"))?; + if is_link_or_reparse_point(&source_metadata) || !source_metadata.is_dir() { + return Err("Source server directory is not safe".to_string()); + } + if std::fs::symlink_metadata(&destination).is_ok() { + return Err("Destination server directory already exists".to_string()); + } + let parent = destination + .parent() + .ok_or_else(|| "Destination server has no parent".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create managed server root: {error}"))?; + copy_managed_tree(&source, &destination, &managed_root) +} + +/// Safely migrates a legacy `servers/` directory to `servers/`. +/// The legacy name is a single directory component; no external path can be +/// supplied and a failed migration never falls back to that external path. +#[tauri::command] +pub async fn migrate_managed_server_directory( + app: AppHandle, + legacy_directory_name: String, + server_id: String, +) -> Result { + let legacy_directory_name = validate_server_id(&legacy_directory_name)?; + let server_id = validate_server_id(&server_id)?; + let app_data_dir = app_data_dir(&app)?; + let source_request = ManagedPathRequest { + root: ManagedRoot::Servers, + server_id: Some(legacy_directory_name.clone()), + relative_path: String::new(), + }; + let destination_request = ManagedPathRequest { + root: ManagedRoot::Servers, + server_id: Some(server_id.clone()), + relative_path: String::new(), + }; + let destination = resolve_managed_request(&app_data_dir, &destination_request, true)?; + let destination_root = std::fs::canonicalize(app_data_dir.join("servers")) + .map_err(|error| format!("Failed to resolve managed servers root: {error}"))?; + if destination.parent() != Some(destination_root.as_path()) { + return Err("Migrated server destination is outside the managed root".to_string()); + } + if legacy_directory_name == server_id { + let metadata = std::fs::symlink_metadata(&destination) + .map_err(|error| format!("Failed to inspect managed server directory: {error}"))?; + if is_link_or_reparse_point(&metadata) || !metadata.is_dir() { + return Err("Managed server directory is not a real directory".to_string()); + } + return Ok(destination.to_string_lossy().to_string()); + } + + if let Ok(metadata) = std::fs::symlink_metadata(&destination) { + if is_link_or_reparse_point(&metadata) || !metadata.is_dir() { + return Err("Migration destination is not a safe directory".to_string()); + } + return Err("Migration destination already exists".to_string()); + } + + let source = match resolve_managed_request(&app_data_dir, &source_request, false) { + Ok(path) => path, + Err(error) => { + return Err(format!( + "Legacy server directory is unavailable; migration was not performed: {error}" + )); + } + }; + let source_metadata = std::fs::symlink_metadata(&source) + .map_err(|error| format!("Failed to inspect legacy server directory: {error}"))?; + if is_link_or_reparse_point(&source_metadata) || !source_metadata.is_dir() { + return Err("Legacy server directory is not a safe directory".to_string()); + } + + if let Err(rename_error) = std::fs::rename(&source, &destination) { + if let Err(copy_error) = copy_managed_tree(&source, &destination, &destination_root) { + let _ = std::fs::remove_dir_all(&destination); + return Err(format!( + "Failed to migrate legacy server directory: {copy_error}; rename error: {rename_error}" + )); + } + std::fs::remove_dir_all(&source) + .map_err(|error| format!("Failed to remove migrated legacy directory: {error}"))?; + } + + Ok(destination.to_string_lossy().to_string()) } -/// ディレクトリの内容をメタデータ付きで一括取得 +/// Returns metadata for a managed directory only. The caller supplies a root, +/// optional server ID, and relative path; arbitrary absolute paths are rejected. #[tauri::command] -pub async fn list_dir_with_metadata(path: String) -> Result, String> { - let dir_path = Path::new(&path); - if !dir_path.exists() { - return Err("Directory does not exist".to_string()); +pub async fn list_dir_with_metadata( + app: AppHandle, + request: ManagedPathRequest, +) -> Result, String> { + let app_data_dir = app_data_dir(&app)?; + let dir_path = resolve_managed_request(&app_data_dir, &request, false)?; + let metadata = + std::fs::symlink_metadata(&dir_path).map_err(|_| "Directory does not exist".to_string())?; + if is_link_or_reparse_point(&metadata) { + return Err("Refusing to list a symbolic link or reparse point".to_string()); } - if !dir_path.is_dir() { + if !metadata.is_dir() { return Err("Path is not a directory".to_string()); } let mut entries = Vec::new(); - let read_dir = - std::fs::read_dir(dir_path).map_err(|e| format!("Failed to read directory: {}", e))?; + let read_dir = std::fs::read_dir(&dir_path) + .map_err(|error| format!("Failed to read directory: {error}"))?; for entry in read_dir { - let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let entry = entry.map_err(|error| format!("Failed to read entry: {error}"))?; let metadata = entry - .metadata() - .map_err(|e| format!("Failed to get metadata: {}", e))?; + .path() + .symlink_metadata() + .map_err(|error| format!("Failed to get metadata: {error}"))?; let name = entry.file_name().to_string_lossy().to_string(); - + let is_directory = metadata.is_dir() && !is_link_or_reparse_point(&metadata); let modified = metadata .modified() .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) .unwrap_or(0); entries.push(FileEntryInfo { name, - is_directory: metadata.is_dir(), - size: if metadata.is_dir() { 0 } else { metadata.len() }, + is_directory, + size: if is_directory { 0 } else { metadata.len() }, modified, }); } - // フォルダ優先、名前順でソート - entries.sort_by(|a, b| { - b.is_directory - .cmp(&a.is_directory) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + entries.sort_by(|left, right| { + right + .is_directory + .cmp(&left.is_directory) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) }); Ok(entries) @@ -320,106 +1221,198 @@ pub async fn list_dir_with_metadata(path: String) -> Result, #[cfg(test)] mod tests { - use super::validate_managed_server_dir_for_delete; - use std::path::PathBuf; + use super::{ + resolve_managed_request, validated_relative_path, ManagedPathRequest, ManagedRoot, + }; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; - fn servers_root() -> PathBuf { - #[cfg(windows)] - { - PathBuf::from(r"C:\mc-vector-file-utils-tests\servers") + static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new() -> Self { + let sequence = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let path = PathBuf::from("target") + .join("mc-vector-file-utils") + .join(format!("{}-{sequence}", std::process::id())); + std::fs::create_dir_all(&path).expect("test directory should be created"); + Self(path) } - #[cfg(not(windows))] - { - PathBuf::from("/tmp/mc-vector-file-utils-tests/servers") + fn path(&self) -> &Path { + &self.0 } } - #[test] - fn delete_validation_allows_direct_managed_server_folder() { - let servers_root = servers_root(); - let server_dir = servers_root.join("alpha"); - - let result = validate_managed_server_dir_for_delete( - &servers_root, - server_dir.to_string_lossy().as_ref(), - ) - .expect("direct managed server folder should be allowed"); + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } - assert_eq!(result, server_dir); + fn server_request(server_id: &str, relative_path: &str) -> ManagedPathRequest { + ManagedPathRequest { + root: ManagedRoot::Servers, + server_id: Some(server_id.to_string()), + relative_path: relative_path.to_string(), + } } #[test] - fn delete_validation_rejects_managed_servers_root() { - let servers_root = servers_root(); + fn resolves_server_relative_path_below_server_id() { + let app_data = TestDirectory::new(); + std::fs::create_dir_all(app_data.path().join("servers/alpha/world")) + .expect("managed server parent should be created"); + let resolved = resolve_managed_request( + app_data.path(), + &server_request("alpha", "world/level.dat"), + true, + ) + .expect("managed server path should resolve"); - let result = validate_managed_server_dir_for_delete( - &servers_root, - servers_root.to_string_lossy().as_ref(), + let canonical_app_data = std::fs::canonicalize(app_data.path()).unwrap(); + assert_eq!( + resolved, + canonical_app_data.join("servers/alpha/world/level.dat") ); - - assert!(result.is_err()); } #[test] - fn delete_validation_rejects_app_data_parent() { - let servers_root = servers_root(); - let app_data = servers_root.parent().unwrap(); - - let result = validate_managed_server_dir_for_delete( - &servers_root, - app_data.to_string_lossy().as_ref(), - ); - - assert!(result.is_err()); + fn resolves_each_shared_managed_root() { + let app_data = TestDirectory::new(); + for root in [ManagedRoot::Java, ManagedRoot::Ngrok] { + let request = ManagedPathRequest { + root, + server_id: None, + relative_path: "artifact.bin".to_string(), + }; + let resolved = resolve_managed_request(app_data.path(), &request, true) + .expect("shared managed root should resolve"); + assert!(resolved.starts_with(std::fs::canonicalize(app_data.path()).unwrap())); + } + let request = ManagedPathRequest { + root: ManagedRoot::Backups, + server_id: Some("alpha".to_string()), + relative_path: "artifact.bin".to_string(), + }; + std::fs::create_dir_all(app_data.path().join("backups/alpha")) + .expect("managed backup parent should be created"); + let resolved = resolve_managed_request(app_data.path(), &request, true) + .expect("backup managed root should resolve"); + assert!(resolved.starts_with(std::fs::canonicalize(app_data.path()).unwrap())); } #[test] - fn delete_validation_rejects_path_traversal() { - let servers_root = servers_root(); - let traversal = servers_root.join("..").join("outside"); + fn server_root_requires_server_id() { + let request = ManagedPathRequest { + root: ManagedRoot::Servers, + server_id: None, + relative_path: "server.properties".to_string(), + }; + let test_dir = TestDirectory::new(); + let error = resolve_managed_request(test_dir.path(), &request, true) + .expect_err("server path without ID must fail"); + assert_eq!(error, "This managed path requires a server ID"); + } - let result = validate_managed_server_dir_for_delete( - &servers_root, - traversal.to_string_lossy().as_ref(), - ); + #[test] + fn shared_roots_reject_server_id() { + let request = ManagedPathRequest { + root: ManagedRoot::Java, + server_id: Some("alpha".to_string()), + relative_path: "bin/java".to_string(), + }; + let test_dir = TestDirectory::new(); + let error = resolve_managed_request(test_dir.path(), &request, true) + .expect_err("shared root with server ID must fail"); + assert_eq!(error, "Only server paths may include a server ID"); + } - assert!(result.is_err()); + #[test] + fn rejects_absolute_paths_and_traversal_in_both_separator_styles() { + for input in [ + "/etc/passwd", + r"C:\\Windows\\System32", + "../outside", + r"..\\outside", + ] { + assert!( + validated_relative_path(input).is_err(), + "{input} should be rejected" + ); + } } #[test] - fn delete_validation_rejects_nested_paths_inside_server_folder() { - let servers_root = servers_root(); - let nested = servers_root.join("alpha").join("world"); + fn rejects_control_characters_and_unsafe_server_ids() { + assert!(validated_relative_path("world/level\u{0000}.dat").is_err()); + let test_dir = TestDirectory::new(); + assert!(resolve_managed_request( + test_dir.path(), + &server_request("alpha/../outside", "server.properties"), + true, + ) + .is_err()); + } - let result = validate_managed_server_dir_for_delete( - &servers_root, - nested.to_string_lossy().as_ref(), - ); + #[test] + fn rejects_missing_non_final_component() { + let app_data = TestDirectory::new(); + let error = resolve_managed_request( + app_data.path(), + &server_request("alpha", "missing/level.dat"), + true, + ) + .expect_err("only the final managed path component may be new"); - assert!(result.is_err()); + assert_eq!(error, "Managed path parent does not exist"); } + #[cfg(unix)] #[test] - fn delete_validation_rejects_external_folder() { - let servers_root = servers_root(); - let external = servers_root.parent().unwrap().join("external-server"); - - let result = validate_managed_server_dir_for_delete( - &servers_root, - external.to_string_lossy().as_ref(), + fn rejects_symbolic_link_managed_root() { + use std::os::unix::fs::symlink; + + let app_data = TestDirectory::new(); + let outside = TestDirectory::new(); + symlink(outside.path(), app_data.path().join("servers")) + .expect("test symlink should be created"); + + let error = resolve_managed_request( + app_data.path(), + &server_request("alpha", "server.properties"), + false, + ) + .expect_err("symlink managed root must fail"); + assert_eq!( + error, + "Refusing to access a symbolic-link or reparse-point managed root" ); - - assert!(result.is_err()); } + #[cfg(unix)] #[test] - fn delete_validation_rejects_empty_server_name() { - let servers_root = servers_root(); - let empty_name = servers_root.to_string_lossy().to_string() + "/"; - - let result = validate_managed_server_dir_for_delete(&servers_root, &empty_name); - - assert!(result.is_err()); + fn rejects_symbolic_link_path_component() { + use std::os::unix::fs::symlink; + + let app_data = TestDirectory::new(); + let server_root = app_data.path().join("servers/alpha"); + let outside = TestDirectory::new(); + std::fs::create_dir_all(&server_root).expect("managed server should be created"); + symlink(outside.path(), server_root.join("link")) + .expect("test symlink component should be created"); + + let error = resolve_managed_request( + app_data.path(), + &server_request("alpha", "link/server.properties"), + false, + ) + .expect_err("symlink path component must fail"); + assert_eq!( + error, + "Managed path contains a symbolic link or reparse point" + ); } } diff --git a/src/lib/__tests__/file-commands.test.ts b/src/lib/__tests__/file-commands.test.ts index a05069c..fc31a93 100644 --- a/src/lib/__tests__/file-commands.test.ts +++ b/src/lib/__tests__/file-commands.test.ts @@ -1,242 +1,118 @@ -import { type MockInstance, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const tauriInvokeMock = vi.fn(); -const copyFileMock = vi.fn(); -const mkdirMock = vi.fn(); -const readDirMock = vi.fn(); -const removeMock = vi.fn(); -const renameMock = vi.fn(); -const openMock = vi.fn(); +const appDataDirMock = vi.fn(); +vi.mock('@tauri-apps/api/path', () => ({ appDataDir: appDataDirMock })); vi.mock('../tauri-api', () => ({ tauriInvoke: tauriInvokeMock, tauriListen: vi.fn() })); -vi.mock('@tauri-apps/plugin-fs', () => ({ - copyFile: copyFileMock, - mkdir: mkdirMock, - readDir: readDirMock, - remove: removeMock, - rename: renameMock, -})); -vi.mock('@tauri-apps/plugin-dialog', () => ({ open: openMock })); vi.mock('@tauri-apps/plugin-opener', () => ({ revealItemInDir: vi.fn() })); -function mockResolvePath() { - (tauriInvokeMock as MockInstance).mockImplementation((cmd: string, args?: unknown) => { - if (cmd === 'resolve_managed_path') { - return Promise.resolve((args as { path: string }).path); - } - return Promise.resolve(undefined); - }); -} +const serverRoot = '/app-data/servers/s1'; beforeEach(() => { vi.resetModules(); tauriInvokeMock.mockReset(); - copyFileMock.mockReset(); - mkdirMock.mockReset(); - readDirMock.mockReset(); - removeMock.mockReset(); - renameMock.mockReset(); - openMock.mockReset(); + appDataDirMock.mockReset(); + appDataDirMock.mockResolvedValue('/app-data'); + tauriInvokeMock.mockImplementation((command: string) => { + if (command === 'resolve_managed_path') { + return Promise.resolve('/resolved/managed/path'); + } + return Promise.resolve(undefined); + }); }); -describe('listFiles', () => { - it('returns DirEntry array from readDir', async () => { - mockResolvePath(); - const entries = [{ name: 'server.jar', isDirectory: false, isFile: true, isSymlink: false }]; - readDirMock.mockResolvedValue(entries); - +describe('managed file commands', () => { + it('lists through the typed managed path contract', async () => { + tauriInvokeMock.mockImplementation((command: string) => { + if (command === 'resolve_managed_path') return Promise.resolve('/resolved'); + if (command === 'list_dir_with_metadata') { + return Promise.resolve([{ name: 'server.jar', isDirectory: false, size: 10, modified: 1 }]); + } + return Promise.resolve(undefined); + }); const { listFiles } = await import('../file-commands'); - const result = await listFiles('/servers/default'); - expect(result).toEqual(entries); - expect(readDirMock).toHaveBeenCalledWith('/servers/default'); + await expect(listFiles(serverRoot)).resolves.toEqual([ + { name: 'server.jar', isDirectory: false, size: 10, modified: 1 }, + ]); + expect(tauriInvokeMock).toHaveBeenCalledWith('list_dir_with_metadata', { + request: { root: 'servers', serverId: 's1', relativePath: '' }, + }); }); - it('throws on empty path', async () => { + it('rejects paths outside app-managed storage before IPC', async () => { const { listFiles } = await import('../file-commands'); - await expect(listFiles('')).rejects.toThrow('Invalid path'); - await expect(listFiles(' ')).rejects.toThrow('Invalid path'); + await expect(listFiles('/tmp/outside')).rejects.toThrow('outside MC-Vector managed storage'); + expect(tauriInvokeMock).not.toHaveBeenCalled(); }); - it('throws on null-byte path', async () => { - const { listFiles } = await import('../file-commands'); - await expect(listFiles('/path\0/foo')).rejects.toThrow('Invalid path'); + it('rejects traversal and missing paths', async () => { + const { listFiles, createFile } = await import('../file-commands'); + await expect(listFiles(`${serverRoot}/../outside`)).rejects.toThrow('Path traversal'); + await expect(createFile(serverRoot, '')).rejects.toThrow('Invalid file or folder name'); }); -}); -describe('saveFileContent', () => { - it('calls write_managed_text_file with resolved path and content', async () => { - tauriInvokeMock.mockResolvedValue('/resolved/server.properties'); - const { saveFileContent } = await import('../file-commands'); - await saveFileContent('/servers/server.properties', 'level-name=world'); - expect(tauriInvokeMock).toHaveBeenCalledWith('resolve_managed_path', { - path: '/servers/server.properties', - }); + it('writes, creates, deletes, and moves using managed requests', async () => { + const { saveFileContent, createFile, createFolder, deleteItem, moveItem } = await import( + '../file-commands' + ); + await saveFileContent(`${serverRoot}/server.properties`, 'level-name=world'); + await createFile(serverRoot, 'new.txt'); + await createFolder(serverRoot, 'world'); + await deleteItem(`${serverRoot}/old.txt`); + await moveItem(`${serverRoot}/old.txt`, `${serverRoot}/new.txt`); + expect(tauriInvokeMock).toHaveBeenCalledWith('write_managed_text_file', { - path: '/resolved/server.properties', + request: { root: 'servers', serverId: 's1', relativePath: 'server.properties' }, content: 'level-name=world', }); - }); -}); - -describe('createFile', () => { - it('creates empty file at resolved path', async () => { - mockResolvePath(); - - const { createFile } = await import('../file-commands'); - await createFile('/servers/default', 'newfile.txt'); - expect(tauriInvokeMock).toHaveBeenCalledWith('write_managed_text_file', { - path: '/servers/default/newfile.txt', - content: '', + expect(tauriInvokeMock).toHaveBeenCalledWith('create_managed_directory', { + request: { root: 'servers', serverId: 's1', relativePath: 'world' }, + }); + expect(tauriInvokeMock).toHaveBeenCalledWith('delete_managed_path', { + request: { root: 'servers', serverId: 's1', relativePath: 'old.txt' }, + }); + expect(tauriInvokeMock).toHaveBeenCalledWith('move_managed_path', { + from: { root: 'servers', serverId: 's1', relativePath: 'old.txt' }, + to: { root: 'servers', serverId: 's1', relativePath: 'new.txt' }, }); }); - it('throws on empty name', async () => { - const { createFile } = await import('../file-commands'); - await expect(createFile('/servers', '')).rejects.toThrow('Invalid file or folder name'); - await expect(createFile('/servers', ' ')).rejects.toThrow('Invalid file or folder name'); - }); - - it('throws when name contains slash', async () => { - const { createFile } = await import('../file-commands'); - await expect(createFile('/servers', 'a/b')).rejects.toThrow('Invalid file or folder name'); - await expect(createFile('/servers', 'a\\b')).rejects.toThrow('Invalid file or folder name'); - }); - - it('throws when name contains ..', async () => { - const { createFile } = await import('../file-commands'); - await expect(createFile('/servers', '..')).rejects.toThrow('Invalid file or folder name'); - await expect(createFile('/servers', 'a..b')).rejects.toThrow('Invalid file or folder name'); - }); -}); - -describe('createFolder', () => { - it('calls mkdir with recursive option', async () => { - mockResolvePath(); - mkdirMock.mockResolvedValue(undefined); - - const { createFolder } = await import('../file-commands'); - await createFolder('/servers', 'myworld'); - expect(mkdirMock).toHaveBeenCalledWith('/servers/myworld', { recursive: true }); - }); - - it('throws on invalid folder name', async () => { - const { createFolder } = await import('../file-commands'); - await expect(createFolder('/servers', '')).rejects.toThrow('Invalid file or folder name'); - }); -}); - -describe('deleteItem', () => { - it('calls remove with resolved path and recursive', async () => { - mockResolvePath(); - removeMock.mockResolvedValue(undefined); - - const { deleteItem } = await import('../file-commands'); - await deleteItem('/servers/old-world'); - expect(removeMock).toHaveBeenCalledWith('/servers/old-world', { recursive: true }); - }); -}); - -describe('moveItem', () => { - it('calls rename with both resolved paths', async () => { - mockResolvePath(); - renameMock.mockResolvedValue(undefined); - - const { moveItem } = await import('../file-commands'); - await moveItem('/servers/a.txt', '/servers/b.txt'); - expect(renameMock).toHaveBeenCalledWith('/servers/a.txt', '/servers/b.txt'); - }); -}); - -describe('readJsonFile', () => { - it('returns parsed object when content is valid JSON object', async () => { - tauriInvokeMock.mockResolvedValue(JSON.stringify({ key: 'value' })); - - const { readJsonFile } = await import('../file-commands'); - const result = await readJsonFile('/config.json'); - expect(result).toEqual({ key: 'value' }); - }); - - it('returns parsed array when content is valid JSON array', async () => { - tauriInvokeMock.mockResolvedValue(JSON.stringify([1, 2, 3])); - - const { readJsonFile } = await import('../file-commands'); - const result = await readJsonFile('/list.json'); - expect(result).toEqual([1, 2, 3]); - }); - - it('returns null when content is primitive JSON', async () => { - tauriInvokeMock.mockResolvedValue('42'); - - const { readJsonFile } = await import('../file-commands'); - const result = await readJsonFile('/num.json'); - expect(result).toBeNull(); - }); - - it('returns null when JSON parse fails', async () => { - tauriInvokeMock.mockResolvedValue('{ invalid json }'); - - const { readJsonFile } = await import('../file-commands'); - const result = await readJsonFile('/broken.json'); - expect(result).toBeNull(); - }); - - it('returns null when tauriInvoke throws', async () => { - tauriInvokeMock.mockRejectedValue(new Error('file not found')); - - const { readJsonFile } = await import('../file-commands'); - const result = await readJsonFile('/missing.json'); - expect(result).toBeNull(); - }); -}); - -describe('writeJsonFile', () => { - it('serializes data and writes via tauriInvoke', async () => { - mockResolvePath(); - - const { writeJsonFile } = await import('../file-commands'); - const data = { server: 'vanilla', port: 25565 }; - await writeJsonFile('/config.json', data); - - expect(tauriInvokeMock).toHaveBeenCalledWith('write_managed_text_file', { - path: '/config.json', - content: JSON.stringify(data, null, 2), + it('uses a native Rust picker for imports', async () => { + tauriInvokeMock.mockImplementation((command: string) => { + if (command === 'resolve_managed_path') return Promise.resolve('/resolved'); + if (command === 'import_managed_files') { + return Promise.resolve([ + { serverId: 's1', relativePath: 'plugins/example.jar', isDirectory: false, size: 42 }, + ]); + } + return Promise.resolve(undefined); }); + const { importFile, importFilesFromPaths } = await import('../file-commands'); + await expect(importFile(`${serverRoot}/plugins`)).resolves.toBe( + `${serverRoot}/plugins/example.jar`, + ); + await expect( + importFilesFromPaths(['/arbitrary/source.jar'], `${serverRoot}/plugins`), + ).resolves.toEqual([`${serverRoot}/plugins/example.jar`]); + expect(tauriInvokeMock).not.toHaveBeenCalledWith( + 'import_managed_files_from_paths', + expect.anything(), + ); }); - it('serializes array data correctly', async () => { - mockResolvePath(); - - const { writeJsonFile } = await import('../file-commands'); - await writeJsonFile('/list.json', [1, 2, 3]); + it('serializes JSON through the managed text command', async () => { + const { readJsonFile, writeJsonFile } = await import('../file-commands'); + tauriInvokeMock.mockImplementation((command: string) => { + if (command === 'resolve_managed_path') return Promise.resolve('/resolved'); + if (command === 'read_managed_text_file') return Promise.resolve('{"key":"value"}'); + return Promise.resolve(undefined); + }); + await expect(readJsonFile(`${serverRoot}/config.json`)).resolves.toEqual({ key: 'value' }); + await writeJsonFile(`${serverRoot}/config.json`, [1, 2, 3]); expect(tauriInvokeMock).toHaveBeenCalledWith('write_managed_text_file', { - path: '/list.json', - content: JSON.stringify([1, 2, 3], null, 2), + request: { root: 'servers', serverId: 's1', relativePath: 'config.json' }, + content: '[\n 1,\n 2,\n 3\n]', }); }); }); - -describe('importFile', () => { - it('returns null when user cancels dialog', async () => { - mockResolvePath(); - openMock.mockResolvedValue(null); - - const { importFile } = await import('../file-commands'); - const result = await importFile('/servers/plugins'); - expect(result).toBeNull(); - expect(copyFileMock).not.toHaveBeenCalled(); - }); - - it('copies selected file and returns destination path', async () => { - mockResolvePath(); - openMock.mockResolvedValue('/downloads/myplugin.jar'); - copyFileMock.mockResolvedValue(undefined); - - const { importFile } = await import('../file-commands'); - const result = await importFile('/servers/plugins'); - expect(copyFileMock).toHaveBeenCalledWith( - '/downloads/myplugin.jar', - '/servers/plugins/myplugin.jar', - ); - expect(result).toBe('/servers/plugins/myplugin.jar'); - }); -}); diff --git a/src/lib/file-commands.ts b/src/lib/file-commands.ts index c6cd3c7..6365da4 100644 --- a/src/lib/file-commands.ts +++ b/src/lib/file-commands.ts @@ -1,8 +1,15 @@ -import { open } from '@tauri-apps/plugin-dialog'; -import { type DirEntry, copyFile, mkdir, readDir, remove, rename } from '@tauri-apps/plugin-fs'; +import { appDataDir } from '@tauri-apps/api/path'; import { revealItemInDir } from '@tauri-apps/plugin-opener'; import { tauriInvoke } from './tauri-api'; +export type ManagedRoot = 'servers' | 'java' | 'ngrok' | 'backups'; + +export interface ManagedPathRequest { + root: ManagedRoot; + serverId?: string; + relativePath: string; +} + export interface FileEntryWithMeta { name: string; isDirectory: boolean; @@ -10,13 +17,66 @@ export interface FileEntryWithMeta { modified: number; // unix timestamp in seconds } -async function assertAllowedPath(path: string): Promise { - if (!path.trim() || path.includes('\0')) { +function normalizePath(input: string): string { + return input + .replace(/\\/g, '/') + .replace(/\/{2,}/g, '/') + .replace(/\/$/, ''); +} + +function hasInvalidRelativeSegment(path: string): boolean { + return path.split('/').some((segment) => !segment || segment === '.' || segment === '..'); +} + +async function toManagedRequest(path: string): Promise { + const normalizedPath = normalizePath(path.trim()); + const dataDir = normalizePath(await appDataDir()); + if (!normalizedPath || normalizedPath.includes('\0')) { throw new Error('Invalid path'); } - return tauriInvoke('resolve_managed_path', { - path, - }); + + const prefix = `${dataDir}/`; + if (!normalizedPath.startsWith(prefix)) { + throw new Error('Path is outside MC-Vector managed storage'); + } + + const segments = normalizedPath.slice(prefix.length).split('/'); + const root = segments.shift(); + if (root !== 'servers' && root !== 'java' && root !== 'ngrok' && root !== 'backups') { + throw new Error('Path is outside MC-Vector managed storage'); + } + + if (root === 'servers' || root === 'backups') { + const serverId = segments.shift(); + if (!serverId || hasInvalidRelativeSegment(serverId)) { + throw new Error('Managed server ID is missing or invalid'); + } + const relativePath = segments.join('/'); + if (relativePath && hasInvalidRelativeSegment(relativePath)) { + throw new Error('Path traversal is not allowed'); + } + return { root, serverId, relativePath }; + } + + const relativePath = segments.join('/'); + if (!relativePath || hasInvalidRelativeSegment(relativePath)) { + throw new Error('Managed relative path is missing or invalid'); + } + return { root, relativePath }; +} + +async function assertAllowedPath(path: string): Promise { + const request = await toManagedRequest(path); + await tauriInvoke('resolve_managed_path', { request }); + return request; +} + +async function resolveAllowedPath( + path: string, +): Promise<{ request: ManagedPathRequest; absolutePath: string }> { + const request = await toManagedRequest(path); + const absolutePath = await tauriInvoke('resolve_managed_path', { request }); + return { request, absolutePath }; } function assertSafeName(name: string): string { @@ -36,113 +96,133 @@ function isJsonContainer(value: unknown): value is Record | unk return Array.isArray(value) || (typeof value === 'object' && value !== null); } -async function writeManagedTextFile(path: string, content: string): Promise { +async function writeManagedTextFile(request: ManagedPathRequest, content: string): Promise { return tauriInvoke('write_managed_text_file', { - path, + request, content, }); } -export async function listFiles(dirPath: string): Promise { - const safeDirPath = await assertAllowedPath(dirPath); - return readDir(safeDirPath); +export async function listFiles(dirPath: string): Promise { + const request = await assertAllowedPath(dirPath); + return tauriInvoke('list_dir_with_metadata', { request }); } export async function listFilesWithMetadata(dirPath: string): Promise { - const safeDirPath = await assertAllowedPath(dirPath); - return tauriInvoke('list_dir_with_metadata', { - path: safeDirPath, - }); + return listFiles(dirPath); } export async function readFileContent(filePath: string): Promise { + const request = await assertAllowedPath(filePath); return tauriInvoke('read_managed_text_file', { - path: filePath, + request, }); } export async function saveFileContent(filePath: string, content: string): Promise { - const safeFilePath = await assertAllowedPath(filePath); - return writeManagedTextFile(safeFilePath, content); + const request = await assertAllowedPath(filePath); + return writeManagedTextFile(request, content); } export async function importFile(destDir: string): Promise { - const safeDestDir = await assertAllowedPath(destDir); - const selected = await open({ multiple: false }); - if (!selected) return null; - const filePath = selected as string; - const fileName = filePath.split('/').pop() ?? filePath.split('\\').pop() ?? 'file'; - const destPath = `${safeDestDir}/${fileName}`; - await copyFile(filePath, destPath); - return destPath; -} - -export async function importFilesFromPaths(paths: string[], destDir: string): Promise { - const safeDestDir = await assertAllowedPath(destDir); - const results: string[] = []; - for (const filePath of paths) { - const normalizedSource = String(filePath); - const fileName = - normalizedSource.split('/').pop() ?? normalizedSource.split('\\').pop() ?? 'file'; - const destPath = `${safeDestDir}/${fileName}`; - await copyFile(normalizedSource, destPath); - results.push(destPath); - } - return results; + const imported = await importFilesDialog(destDir); + return imported[0] ?? null; +} + +export async function importFilesFromPaths(_paths: string[], destDir: string): Promise { + // The native picker owns source selection. Keeping dropped paths out of IPC + // prevents the renderer from turning this into an arbitrary file-copy API. + return importFilesDialog(destDir); } export async function importFilesDialog(destDir: string): Promise { - const selected = await open({ multiple: true }); - if (!selected) return []; - const files = Array.isArray(selected) ? selected : [selected]; - return importFilesFromPaths( - files.map((filePath) => filePath as string), - destDir, - ); + const request = await assertAllowedPath(destDir); + const imported = await tauriInvoke< + Array<{ serverId?: string; relativePath: string; isDirectory: boolean; size: number }> + >('import_managed_files', { request }); + const dataDir = normalizePath(await appDataDir()); + return imported.map((entry) => { + const serverSegment = entry.serverId ? `/${entry.serverId}` : ''; + return `${dataDir}/${request.root}${serverSegment}/${entry.relativePath}`; + }); } export async function createFile(dirPath: string, name: string): Promise { const safeDirPath = await assertAllowedPath(dirPath); const safeName = assertSafeName(name); - await writeManagedTextFile(`${safeDirPath}/${safeName}`, ''); + const request: ManagedPathRequest = { + ...safeDirPath, + relativePath: safeDirPath.relativePath ? `${safeDirPath.relativePath}/${safeName}` : safeName, + }; + await writeManagedTextFile(request, ''); } export async function createFolder(dirPath: string, name: string): Promise { const safeDirPath = await assertAllowedPath(dirPath); const safeName = assertSafeName(name); - await mkdir(`${safeDirPath}/${safeName}`, { recursive: true }); + const request: ManagedPathRequest = { + ...safeDirPath, + relativePath: safeDirPath.relativePath ? `${safeDirPath.relativePath}/${safeName}` : safeName, + }; + await tauriInvoke('create_managed_directory', { request }); +} + +export async function createManagedServerDirectory(serverId: string): Promise { + const dataDir = normalizePath(await appDataDir()); + const request: ManagedPathRequest = { + root: 'servers', + serverId, + relativePath: '', + }; + await tauriInvoke('create_managed_directory', { request }); + return `${dataDir}/servers/${serverId}`; +} + +export async function deleteManagedServerDirectory(serverId: string): Promise { + await tauriInvoke('delete_managed_server_dir', { serverId }); +} + +export async function cloneManagedServer( + sourceServerId: string, + destinationServerId: string, +): Promise { + await tauriInvoke('clone_managed_server', { sourceServerId, destinationServerId }); } export async function deleteItem(path: string): Promise { const safePath = await assertAllowedPath(path); - await remove(safePath, { recursive: true }); + await tauriInvoke('delete_managed_path', { request: safePath }); } export async function moveItem(from: string, to: string): Promise { const safeFrom = await assertAllowedPath(from); const safeTo = await assertAllowedPath(to); - await rename(safeFrom, safeTo); + await tauriInvoke('move_managed_path', { from: safeFrom, to: safeTo }); } export async function compressItem(sources: string | string[], dest?: string): Promise { const sourceList = Array.isArray(sources) ? sources : [sources]; const safeSources = await Promise.all(sourceList.map((source) => assertAllowedPath(source))); - const destination = await assertAllowedPath(dest || `${safeSources[0]}.zip`); - return tauriInvoke('compress_item', { + const destinationPath = dest || `${sourceList[0]}.zip`; + const destination = await assertAllowedPath(destinationPath); + return tauriInvoke('compress_managed_items', { sources: safeSources, - dest: destination, + destination, }); } export async function extractItem(archivePath: string, destPath: string): Promise { const safeArchivePath = await assertAllowedPath(archivePath); const safeDestPath = await assertAllowedPath(destPath); - return tauriInvoke('extract_item', { archive: safeArchivePath, dest: safeDestPath }); + return tauriInvoke('extract_managed_item', { + archive: safeArchivePath, + destination: safeDestPath, + }); } export async function openInFinder(path: string): Promise { - const safePath = await assertAllowedPath(path); - await revealItemInDir(safePath); + const { absolutePath } = await resolveAllowedPath(path); + await revealItemInDir(absolutePath); } export async function readJsonFile(filePath: string): Promise { diff --git a/src/lib/server-import-commands.ts b/src/lib/server-import-commands.ts index c6e9b74..207f187 100644 --- a/src/lib/server-import-commands.ts +++ b/src/lib/server-import-commands.ts @@ -1,88 +1,22 @@ -import { listFiles, readFileContent } from './file-commands'; +import { tauriInvoke } from './tauri-api'; export interface ServerFolderAnalysis { - folderPath: string; + token: string; + folderName: string; detectedVersion: string; detectedSoftware: string; eulaAccepted: boolean; hasServerJar: boolean; } -const SOFTWARE_PATTERNS: Array<{ pattern: RegExp; name: string }> = [ - { pattern: /paper/i, name: 'Paper' }, - { pattern: /purpur/i, name: 'Purpur' }, - { pattern: /spigot/i, name: 'Spigot' }, - { pattern: /craftbukkit/i, name: 'CraftBukkit' }, - { pattern: /fabric/i, name: 'Fabric' }, - { pattern: /forge/i, name: 'Forge' }, - { pattern: /neoforge/i, name: 'NeoForge' }, - { pattern: /velocity/i, name: 'Velocity' }, - { pattern: /waterfall/i, name: 'Waterfall' }, - { pattern: /vanilla|minecraft_server/i, name: 'Vanilla' }, -]; - -function detectSoftwareFromJarName(jarName: string): string { - for (const { pattern, name } of SOFTWARE_PATTERNS) { - if (pattern.test(jarName)) return name; - } - return 'Paper'; -} - -function parseVersionFromJarName(jarName: string): string { - const match = jarName.match(/1\.\d+(?:\.\d+)?/); - return match ? match[0] : ''; +export async function pickServerImport(): Promise { + return tauriInvoke('pick_server_import'); } -function parseVersionFromProperties(content: string): string { - const match = content.match(/motd=.*?(1\.\d+(?:\.\d+)?)/); - if (match) return match[1]; - const versionMatch = content.match(/^#.*?(1\.\d+(?:\.\d+)?)/m); - return versionMatch ? versionMatch[1] : ''; +export async function completeServerImport(token: string, serverId: string): Promise { + await tauriInvoke('complete_server_import', { token, serverId }); } -export async function analyzeServerFolder(folderPath: string): Promise { - const entries = await listFiles(folderPath); - - const jarEntry = entries.find( - (e) => !e.isDirectory && e.name.endsWith('.jar') && e.name !== 'bundler.jar', - ); - const hasServerJar = Boolean(jarEntry); - - let detectedSoftware = 'Paper'; - let detectedVersion = ''; - - if (jarEntry) { - detectedSoftware = detectSoftwareFromJarName(jarEntry.name); - detectedVersion = parseVersionFromJarName(jarEntry.name); - } - - const propertiesEntry = entries.find((e) => !e.isDirectory && e.name === 'server.properties'); - if (propertiesEntry && !detectedVersion) { - try { - const content = await readFileContent(`${folderPath}/${propertiesEntry.name}`); - const parsed = parseVersionFromProperties(content); - if (parsed) detectedVersion = parsed; - } catch { - // ignore - } - } - - let eulaAccepted = false; - const eulaEntry = entries.find((e) => !e.isDirectory && e.name === 'eula.txt'); - if (eulaEntry) { - try { - const content = await readFileContent(`${folderPath}/${eulaEntry.name}`); - eulaAccepted = /eula\s*=\s*true/i.test(content); - } catch { - // ignore - } - } - - return { - folderPath, - detectedVersion, - detectedSoftware, - eulaAccepted, - hasServerJar, - }; +export async function cancelServerImport(token: string): Promise { + await tauriInvoke('cancel_server_import', { token }); } diff --git a/src/renderer/components/AppServerSidebar.tsx b/src/renderer/components/AppServerSidebar.tsx index 43883c3..399a9de 100644 --- a/src/renderer/components/AppServerSidebar.tsx +++ b/src/renderer/components/AppServerSidebar.tsx @@ -142,6 +142,7 @@ export default function AppServerSidebar({ type="button" className={`app-sidebar__server-item flex-1 ${server.id === selectedServerId ? 'is-active' : ''}`} data-testid={`server-card-${server.id}`} + title={server.unavailableReason} onClick={() => { if (isBulkMode) { toggleSelect(server.id); @@ -155,6 +156,11 @@ export default function AppServerSidebar({ {server.name} + {server.unavailableReason && ( + + {server.unavailableReason} + + )} {server.profileName && ( {server.profileName} )} diff --git a/src/renderer/components/ImportServerModal.tsx b/src/renderer/components/ImportServerModal.tsx index 33eaba3..55fa41b 100644 --- a/src/renderer/components/ImportServerModal.tsx +++ b/src/renderer/components/ImportServerModal.tsx @@ -1,10 +1,9 @@ import { cn } from '@/lib/ui'; import * as Dialog from '@radix-ui/react-dialog'; -import { open } from '@tauri-apps/plugin-dialog'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { useTranslation } from '../../i18n'; -import { analyzeServerFolder } from '../../lib/server-import-commands'; +import { cancelServerImport, pickServerImport } from '../../lib/server-import-commands'; interface ImportServerModalProps { open: boolean; @@ -36,28 +35,37 @@ export default function ImportServerModal({ const [hasServerJar, setHasServerJar] = useState(false); const [analyzed, setAnalyzed] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false); + const [importToken, setImportToken] = useState(null); + const hasSubmitted = useRef(false); - const handleSelectFolder = async () => { - const selected = await open({ directory: true, multiple: false }); - if (!selected || typeof selected !== 'string') { - return; + useEffect(() => { + if (isOpen) { + hasSubmitted.current = false; } + }, [isOpen]); + const handleSelectFolder = async () => { setIsAnalyzing(true); try { - const analysis = await analyzeServerFolder(selected); + if (importToken) { + await cancelServerImport(importToken); + } + const analysis = await pickServerImport(); + if (!analysis) { + return; + } if (!analysis.hasServerJar) { + await cancelServerImport(analysis.token); showToast(t('importServer.toast.noJar'), 'error'); - setIsAnalyzing(false); return; } - setFolderPath(selected); + setImportToken(analysis.token); + setFolderPath(analysis.folderName); setHasServerJar(analysis.hasServerJar); setEulaAccepted(analysis.eulaAccepted); setVersion(analysis.detectedVersion); setSoftware(analysis.detectedSoftware); - const folderName = selected.replace(/\\/g, '/').split('/').at(-1) ?? 'imported-server'; - setServerName(folderName); + setServerName(analysis.folderName); setAnalyzed(true); } catch { showToast(t('importServer.toast.failed'), 'error'); @@ -67,26 +75,35 @@ export default function ImportServerModal({ }; const handleImport = () => { - if (!folderPath || !serverName) { + if (!importToken || !serverName) { return; } + hasSubmitted.current = true; onAdd({ name: serverName, version, software, port: 25565, memory: 4, - path: folderPath, + importToken, }); showToast(t('importServer.toast.success'), 'success'); onClose(); }; + const handleCancel = () => { + if (!hasSubmitted.current && importToken) { + void cancelServerImport(importToken).catch(() => undefined); + } + setImportToken(null); + onClose(); + }; + return ( { - if (!o) onClose(); + if (!o) handleCancel(); }} > @@ -154,7 +171,7 @@ export default function ImportServerModal({
- diff --git a/src/renderer/hooks/use-server-context-actions.ts b/src/renderer/hooks/use-server-context-actions.ts index 3094da4..6df363b 100644 --- a/src/renderer/hooks/use-server-context-actions.ts +++ b/src/renderer/hooks/use-server-context-actions.ts @@ -1,9 +1,9 @@ import { ask } from '@tauri-apps/plugin-dialog'; -import { copyFile, mkdir, readDir } from '@tauri-apps/plugin-fs'; import { useCallback } from 'react'; import type { Translate } from '../../i18n'; import { getServerRoot } from '../../lib/config-commands'; import { logError } from '../../lib/error-utils'; +import { cloneManagedServer } from '../../lib/file-commands'; import { type ServerTemplate, addServer as addServerApi, @@ -15,7 +15,6 @@ import type { ToastKind } from '../shared/toast'; type SetServers = ( nextServers: MinecraftServer[] | ((prevServers: MinecraftServer[]) => MinecraftServer[]), ) => void; -const WINDOWS_DRIVE_ROOT = /^[A-Za-z]:\/$/; interface UseServerContextActionsOptions { servers: MinecraftServer[]; @@ -50,45 +49,6 @@ function buildTemplateFromServer(server: MinecraftServer, templateName: string): }; } -function normalizePath(input: string): string { - const normalized = input.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); - if (normalized.length > 1 && normalized.endsWith('/') && !WINDOWS_DRIVE_ROOT.test(normalized)) { - return normalized.slice(0, -1); - } - return normalized; -} - -function isDirectManagedServerPath(serverPath: string, serverRoot: string): boolean { - const normalizedServerPath = normalizePath(serverPath.trim()); - const normalizedServerRoot = normalizePath(serverRoot.trim()); - if (!normalizedServerPath || normalizedServerPath === normalizedServerRoot) { - return false; - } - - const parentPath = normalizedServerPath.split('/').slice(0, -1).join('/'); - return parentPath === normalizedServerRoot; -} - -async function cloneServerDirectory(sourceDir: string, targetDir: string): Promise { - await mkdir(targetDir, { recursive: true }); - - const entries = await readDir(sourceDir); - for (const entry of entries) { - const entryName = entry.name; - if (!entryName) { - continue; - } - - const sourcePath = `${sourceDir}/${entryName}`; - const targetPath = `${targetDir}/${entryName}`; - if (entry.isDirectory) { - await cloneServerDirectory(sourcePath, targetPath); - } else { - await copyFile(sourcePath, targetPath); - } - } -} - export function useServerContextActions({ servers, setServers, @@ -106,20 +66,8 @@ export function useServerContextActions({ return; } - let isManagedServer = false; - try { - isManagedServer = isDirectManagedServerPath(target.path, await getServerRoot()); - } catch (error) { - logError('Resolve server root for delete confirmation failed', error, { - serverId, - serverPath: target.path, - }); - showToast(t('server.toast.deleteError'), 'error'); - return; - } - const confirmed = await ask( - t(isManagedServer ? 'server.confirm.deleteManaged' : 'server.confirm.deleteExternal', { + t('server.confirm.deleteManaged', { name: target.name, path: target.path, }), @@ -175,20 +123,13 @@ export function useServerContextActions({ } try { - const basePath = `${target.path}-clone`; - const existingPaths = new Set(servers.map((server) => server.path)); - let candidatePath = basePath; - let suffix = 1; - while (existingPaths.has(candidatePath)) { - candidatePath = `${basePath}-${suffix}`; - suffix += 1; - } - - await cloneServerDirectory(target.path, candidatePath); + const duplicatedId = crypto.randomUUID(); + await cloneManagedServer(target.id, duplicatedId); + const candidatePath = `${await getServerRoot()}/${duplicatedId}`; const duplicatedServer: MinecraftServer = { ...target, - id: crypto.randomUUID(), + id: duplicatedId, name: t('server.create.cloneDefaultName', { name: target.name }), path: candidatePath, status: 'offline', diff --git a/src/renderer/shared/server declaration.ts b/src/renderer/shared/server declaration.ts index 06077b6..b33ac67 100644 --- a/src/renderer/shared/server declaration.ts +++ b/src/renderer/shared/server declaration.ts @@ -34,6 +34,7 @@ export interface MinecraftServer { notifyOnStart?: boolean; notifyOnHighCpu?: boolean; notifyHighCpuThreshold?: number; + unavailableReason?: string; } export type AppView =