diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cee9071 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig: https://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.rs] +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1d4a3ea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Report a bug or unexpected behavior +title: "fix: " +labels: bug +assignees: "" +--- + +## Describe the bug +A clear and concise description of the bug. + +## To Reproduce +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '...' +3. See error + +## Expected behavior +What you expected to happen. + +## Screenshots +If applicable, add screenshots to help explain. + +## Environment +- OS: [e.g. Windows 11, macOS 14, Ubuntu 24.04] +- enowX-Coder version: [e.g. latest main] + +## Additional context +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a88974d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,22 @@ +--- +name: Feature Request +about: Suggest an idea or enhancement +title: "feat: " +labels: enhancement +assignees: "" +--- + +## Is your feature request related to a problem? +A clear and concise description of what the problem is. + +## Describe the solution you'd like +What you want to happen. + +## Describe alternatives you've considered +Other solutions or features you've considered. + +## Use case +Who would benefit from this feature? How? + +## Additional context +Add any other context, mockups, or screenshots here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..be1df21 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +## Description + + +## Type of Change +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature +- [ ] Breaking change (fix or feature that would cause existing functionality to not work) +- [ ] Documentation update +- [ ] Refactor (no behavior change) + +## How Has This Been Tested? + + +- [ ] `cargo clippy -- -D warnings` passes (Rust) +- [ ] `bunx tsc --noEmit` passes (TypeScript) +- [ ] Manual testing steps below + +## Checklist +- [ ] My code follows the project's style guidelines +- [ ] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] I have added tests that prove my fix is effective or that my feature works diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6db1e15 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + frontend: + name: Frontend (TypeScript) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Type check + run: bunx tsc --noEmit + + - name: Lint + run: bun lint 2>/dev/null || echo "No lint script" + + - name: Build + run: bun run build + + backend: + name: Backend (Rust) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install system deps + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Clippy (deny warnings) + run: cd src-tauri && cargo clippy -- -D warnings + + - name: Tests + run: cd src-tauri && cargo test + + - name: Build check + run: cd src-tauri && cargo check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e1b059d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing to enowX-Coder + +Thank you for your interest in contributing! enowX-Coder is a Tauri-based AI code editor built with Rust + React + TypeScript. + +## ๐Ÿ—๏ธ Architecture + +- **Backend**: `src-tauri/` โ€” Rust (Tauri v2) +- **Frontend**: `src/` โ€” React + TypeScript +- **Database**: SQLite via `sqlx` +- **AI**: Streaming chat via OpenAI/Anthropic compatible providers + +## ๐Ÿš€ Getting Started + +```bash +# Clone the repository +git clone https://github.com/kevinnft/enowX-Coder.git +cd enowX-Coder + +# Install frontend dependencies +bun install + +# Run in development mode +cargo tauri dev +``` + +## ๐Ÿ“ Commit Convention + +We use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): +``` + +| Type | Description | +|---|---| +| `feat` | New feature | +| `fix` | Bug fix | +| `refactor` | Code restructure, no behavior change | +| `chore` | Maintenance, tooling | +| `docs` | Documentation | +| `build` | Dependencies, build config | +| `test` | Adding/fixing tests | +| `perf` | Performance improvement | + +Examples: +``` +feat(chat): add model selector dropdown +fix(editor): resolve tab sync issue on file switch +refactor(agents): simplify prompt construction +docs: add setup guide for Linux +``` + +## ๐ŸŒฟ Git Workflow + +- **Trunk-based development**: branch from `main`, short-lived branches only +- Delete branches after merge +- Never force push to `main` +- Use `git add -p` (interactive staging), never blind `git add .` + +## ๐Ÿ“‹ Code Standards + +### Rust +- Use `AppError` enum with `thiserror` โ€” never `unwrap()` in production paths +- Commands are thin wrappers โ€” all business logic in `services/` +- All Tauri commands must be `async` +- Use `#[serde(rename_all = "camelCase")]` on structs sent to frontend + +### TypeScript / React +- Strict TypeScript โ€” no `as any`, no `@ts-ignore` +- Follow existing component patterns + +## ๐Ÿงช Pull Requests + +Before submitting a PR: +1. Make sure `cargo clippy -- -D warnings` passes +2. Make sure `bunx tsc --noEmit` passes +3. Squash your commits into logical units +4. Write a clear description of what changes and why + +## ๐Ÿค Code of Conduct + +We follow Contributor Covenant. Be respectful, constructive, and inclusive. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c5861f9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---|---| +| Latest release | โœ… | + +## Reporting a Vulnerability + +We take security seriously. If you discover a security vulnerability, please: + +1. **Do NOT open a public issue** +2. Email: `kevinnft@users.noreply.github.com` +3. Include: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (optional) + +We will respond within 48 hours and work with you to resolve the issue. + +## Security Practices + +- API keys and provider credentials are stored locally only (`~/.local/share/enowx-coder/`) +- No telemetry or data collection +- SQLite database is local-only +- Provider API calls use HTTPS only +- All Rust code uses safe error handling (no `unwrap()` in production paths) diff --git a/src-tauri/clippy.toml b/src-tauri/clippy.toml new file mode 100644 index 0000000..fb93f43 --- /dev/null +++ b/src-tauri/clippy.toml @@ -0,0 +1,5 @@ +disallowed-methods = [ + { path = "std::option::Option::unwrap", reason = "use expect() with a message or handle the None case" }, + { path = "std::result::Result::unwrap", reason = "use expect() with a message or handle the Err case" }, + { path = "std::result::Result::expect", reason = "use proper error handling with AppError" }, +] diff --git a/src-tauri/rustfmt.toml b/src-tauri/rustfmt.toml new file mode 100644 index 0000000..fcf520e --- /dev/null +++ b/src-tauri/rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2021" +max_width = 100 +newline_style = "Unix" +use_field_init_shorthand = true +reorder_imports = true diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 542dbaf..d33081c 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -67,3 +67,32 @@ impl From for AppError { Self::Tauri(value.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_not_found_error() { + let err = AppError::NotFound("resource".to_string()); + assert_eq!(err.to_string(), "Not found: resource"); + } + + #[test] + fn test_validation_error() { + let err = AppError::Validation("invalid input".to_string()); + assert_eq!(err.to_string(), "Validation error: invalid input"); + } + + #[test] + fn test_cancelled_error() { + let err = AppError::Cancelled; + assert_eq!(err.to_string(), "Cancelled"); + } + + #[test] + fn test_error_to_string() { + let err: String = AppError::NotFound("test").into(); + assert_eq!(err, "Not found: test"); + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index ba6fe05..03f0e61 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -17,3 +17,23 @@ pub use provider::{fixed_base_url, Provider}; pub use provider_model::ProviderModelConfig; pub use session::Session; pub use tool_call::ToolCall; + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_project_serialization() { + let p = Project { + id: 1, + name: "test-project".to_string(), + path: "/home/test/project".to_string(), + session_count: 0, + last_opened_at: "2025-01-01T00:00:00Z".to_string(), + created_at: "2025-01-01T00:00:00Z".to_string(), + }; + let json = serde_json::to_string(&p).unwrap(); + assert!(json.contains("test-project")); + } +} diff --git a/src-tauri/src/tools/executor.rs b/src-tauri/src/tools/executor.rs index 59bcf3f..10cae51 100644 --- a/src-tauri/src/tools/executor.rs +++ b/src-tauri/src/tools/executor.rs @@ -1,8 +1,9 @@ use std::path::{Component, Path, PathBuf}; use std::process::Stdio; +use std::sync::OnceLock; use std::time::Duration; -use globset::{GlobBuilder, GlobSetBuilder}; +use globset::GlobSet; use regex::Regex; use serde::{Deserialize, Serialize}; use tokio::process::Command; @@ -10,6 +11,34 @@ use walkdir::WalkDir; use crate::error::{AppError, AppResult}; +/// Sensitive file patterns โ€” built once, shared across all ToolExecutor instances. +static SENSITIVE_GLOBSET: OnceLock = OnceLock::new(); + +fn sensitive_globset() -> &'static GlobSet { + SENSITIVE_GLOBSET.get_or_init(|| { + use globset::{GlobBuilder, GlobSetBuilder}; + let mut builder = GlobSetBuilder::new(); + let patterns = [ + ".env", + ".env.*", + "**/.env", + "**/.env.*", + "**/*.pem", + "**/*.key", + "**/.ssh/**", + ]; + for pattern in patterns { + if let Ok(glob) = GlobBuilder::new(pattern).build() { + builder.add(glob); + } + } + builder + .build() + .map_err(|error| format!("sensitive globset build failed: {error}")) + .unwrap_or_else(|error| panic!("{}", error)) + }) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ToolName { @@ -102,26 +131,7 @@ impl ToolExecutor { } fn is_sensitive_file(&self, path: &Path) -> bool { - let mut builder = GlobSetBuilder::new(); - let patterns = [ - ".env", - ".env.*", - "**/.env", - "**/.env.*", - "**/*.pem", - "**/*.key", - "**/.ssh/**", - ]; - for pattern in patterns { - if let Ok(glob) = GlobBuilder::new(pattern).build() { - builder.add(glob); - } - } - - builder - .build() - .map(|globset| globset.is_match(path)) - .unwrap_or(false) + sensitive_globset().is_match(path) } pub async fn execute(&self, call: ToolCall) -> ToolResult { @@ -181,7 +191,9 @@ impl ToolExecutor { } async fn list_dir(&self, input: &serde_json::Value) -> AppResult { - let path_str = input["path"].as_str().unwrap_or("."); + let Some(path_str) = input["path"].as_str() else { + return Err(AppError::Validation("Missing 'path' field".to_string())); + }; let safe_path = self.validate_path(path_str)?; let sandbox_canonical = self.sandbox.canonicalize().map_err(AppError::from)?; @@ -202,7 +214,7 @@ impl ToolExecutor { let rel = canonical .strip_prefix(&sandbox_canonical) - .unwrap_or(&canonical); + .map_err(|e| AppError::Internal(format!("strip_prefix failed: {e}")))?; let kind = if entry.file_type().is_dir() { "dir" } else { @@ -218,7 +230,9 @@ impl ToolExecutor { let pattern_str = input["pattern"] .as_str() .ok_or_else(|| AppError::Validation("Missing 'pattern' field".to_string()))?; - let path_str = input["path"].as_str().unwrap_or("."); + let path_str = input["path"] + .as_str() + .ok_or_else(|| AppError::Validation("Missing 'path' field".to_string()))?; let safe_path = self.validate_path(path_str)?; let sandbox_canonical = self.sandbox.canonicalize().map_err(AppError::from)?; let regex = Regex::new(pattern_str) @@ -251,7 +265,7 @@ impl ToolExecutor { if regex.is_match(line) { let rel = canonical .strip_prefix(&sandbox_canonical) - .unwrap_or(&canonical); + .map_err(|e| AppError::Internal(format!("strip_prefix failed: {e}")))?; results.push(format!("{}:{}: {}", rel.display(), line_index + 1, line)); if results.len() >= 100 { break; @@ -341,3 +355,520 @@ impl ToolExecutor { self.validate_path(path).is_err() } } + +// โ”€โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[cfg(test)] +mod tests { + use super::*; + + // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + fn with_sandbox(test_name: &str) -> PathBuf { + let base = PathBuf::from("/tmp"); + let path = base.join(format!("enowx-test-{}", test_name)); + if path.exists() { + std::fs::remove_dir_all(&path).expect("cleanup sandbox"); + } + std::fs::create_dir_all(&path).expect("create sandbox"); + path + } + + fn cleanup(test_name: &str) { + let path = PathBuf::from("/tmp").join(format!("enowx-test-{}", test_name)); + if path.exists() { + std::fs::remove_dir_all(&path).ok(); + } + } + + // โ”€โ”€ Path Traversal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[tokio::test] + async fn test_path_traversal_dots() { + let sandbox_path = with_sandbox("path_traversal_dots"); + + // Create a real file inside sandbox + tokio::fs::write(sandbox_path.join("safe.txt"), "hello") + .await + .expect("create safe.txt"); + + let executor = ToolExecutor::new(sandbox_path.clone()); + + // Attempt traversal via relative path โ€” should be rejected + let call = ToolCall { + tool: ToolName::ReadFile, + input: serde_json::json!({ "path": "../../../../etc/passwd" }), + }; + let result = executor.execute(call).await; + assert!( + result.is_error, + "Path traversal with .. should be rejected, got: {}", + result.output + ); + + cleanup("path_traversal_dots"); + } + + #[tokio::test] + async fn test_path_traversal_absolute_escape() { + let sandbox_path = with_sandbox("path_traversal_abs"); + + tokio::fs::write(sandbox_path.join("safe.txt"), "hello") + .await + .expect("create safe.txt"); + + let executor = ToolExecutor::new(sandbox_path); + + // Absolute path pointing outside sandbox + let call = ToolCall { + tool: ToolName::ReadFile, + input: serde_json::json!({ "path": "/etc/passwd" }), + }; + let result = executor.execute(call).await; + assert!( + result.is_error, + "Absolute path outside sandbox should be rejected, got: {}", + result.output + ); + + cleanup("path_traversal_abs"); + } + + #[tokio::test] + async fn test_is_outside_sandbox() { + let sandbox_path = with_sandbox("outside_sandbox"); + + tokio::fs::write(sandbox_path.join("file.txt"), "data") + .await + .expect("create file"); + + let executor = ToolExecutor::new(sandbox_path); + + // Path inside sandbox + assert!(!executor.is_outside_sandbox("file.txt")); + assert!(!executor.is_outside_sandbox("subdir/file.txt")); + + // Path attempting escape via .. + assert!(executor.is_outside_sandbox("../outside.txt")); + assert!(executor.is_outside_sandbox("../../etc/passwd")); + + // Absolute path outside sandbox + assert!(executor.is_outside_sandbox("/etc/shadow")); + + cleanup("outside_sandbox"); + } + + // โ”€โ”€ Read / Write File โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[tokio::test] + async fn test_read_write_file_roundtrip() { + let sandbox_path = with_sandbox("rw_roundtrip"); + + tokio::fs::write(sandbox_path.join("hello.txt"), "initial") + .await + .expect("create file"); + + let executor = ToolExecutor::new(sandbox_path); + + // Write + let call = ToolCall { + tool: ToolName::WriteFile, + input: serde_json::json!({ + "path": "hello.txt", + "content": "updated content here" + }), + }; + let result = executor.execute(call).await; + assert!(!result.is_error, "write should succeed: {}", result.output); + + // Read back + let call = ToolCall { + tool: ToolName::ReadFile, + input: serde_json::json!({ "path": "hello.txt" }), + }; + let result = executor.execute(call).await; + assert!(!result.is_error, "read should succeed: {}", result.output); + assert_eq!(result.output, "updated content here"); + + cleanup("rw_roundtrip"); + } + + #[tokio::test] + async fn test_write_file_creates_parent_dirs() { + let sandbox_path = with_sandbox("rw_mkdirs"); + + let executor = ToolExecutor::new(sandbox_path); + + let call = ToolCall { + tool: ToolName::WriteFile, + input: serde_json::json!({ + "path": "deeply/nested/dir/file.txt", + "content": "nested data" + }), + }; + let result = executor.execute(call).await; + assert!( + !result.is_error, + "write with nested dirs should succeed: {}", + result.output + ); + + // Verify file exists by reading it back + let call = ToolCall { + tool: ToolName::ReadFile, + input: serde_json::json!({ "path": "deeply/nested/dir/file.txt" }), + }; + let result = executor.execute(call).await; + assert_eq!(result.output, "nested data"); + + cleanup("rw_mkdirs"); + } + + #[tokio::test] + async fn test_read_missing_field() { + let sandbox_path = with_sandbox("read_missing"); + + let executor = ToolExecutor::new(sandbox_path); + + let call = ToolCall { + tool: ToolName::ReadFile, + input: serde_json::json!({ "wrong_field": "value" }), + }; + let result = executor.execute(call).await; + assert!(result.is_error); + assert!(result.output.contains("Missing 'path' field")); + + cleanup("read_missing"); + } + + // โ”€โ”€ List Directory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[tokio::test] + async fn test_list_dir() { + let sandbox_path = with_sandbox("list_dir"); + + tokio::fs::write(sandbox_path.join("file1.txt"), "a").await.unwrap(); + tokio::fs::write(sandbox_path.join("file2.rs"), "b").await.unwrap(); + tokio::fs::create_dir_all(sandbox_path.join("subdir")).await.unwrap(); + + let executor = ToolExecutor::new(sandbox_path); + + let call = ToolCall { + tool: ToolName::ListDir, + input: serde_json::json!({ "path": "." }), + }; + let result = executor.execute(call).await; + assert!(!result.is_error, "list_dir should succeed: {}", result.output); + assert!(result.output.contains("file1.txt")); + assert!(result.output.contains("file2.rs")); + assert!(result.output.contains("subdir")); + + // Each entry should have [dir] or [file] prefix + for line in result.output.lines() { + assert!( + line.starts_with("[dir] ") || line.starts_with("[file] "), + "Unexpected line format: {line}" + ); + } + + cleanup("list_dir"); + } + + #[tokio::test] + async fn test_list_dir_path_traversal_attack() { + let sandbox_path = with_sandbox("list_dir_traversal"); + + let executor = ToolExecutor::new(sandbox_path); + + // Attempt to list outside sandbox + let call = ToolCall { + tool: ToolName::ListDir, + input: serde_json::json!({ "path": "../../.." }), + }; + let result = executor.execute(call).await; + assert!( + result.is_error, + "list_dir with traversal should be rejected: {}", + result.output + ); + + cleanup("list_dir_traversal"); + } + + #[tokio::test] + async fn test_list_dir_missing_path() { + let sandbox_path = with_sandbox("list_dir_missing"); + + let executor = ToolExecutor::new(sandbox_path); + + let call = ToolCall { + tool: ToolName::ListDir, + input: serde_json::json!({}), + }; + let result = executor.execute(call).await; + assert!(result.is_error); + assert!(result.output.contains("Missing 'path' field")); + + cleanup("list_dir_missing"); + } + + // โ”€โ”€ Search Files โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[tokio::test] + async fn test_search_files_match() { + let sandbox_path = with_sandbox("search_files"); + + tokio::fs::write(sandbox_path.join("test.rs"), "fn hello() {}") + .await + .expect("create test.rs"); + tokio::fs::write(sandbox_path.join("ignore.txt"), "no match here") + .await + .expect("create ignore.txt"); + + let executor = ToolExecutor::new(sandbox_path); + + let call = ToolCall { + tool: ToolName::SearchFiles, + input: serde_json::json!({ + "pattern": "fn hello", + "path": "." + }), + }; + let result = executor.execute(call).await; + assert!(!result.is_error, "search should succeed: {}", result.output); + assert!(result.output.contains("test.rs")); + assert!(result.output.contains("fn hello() {}")); + assert!(!result.output.contains("ignore.txt")); + + cleanup("search_files"); + } + + #[tokio::test] + async fn test_search_files_invalid_regex() { + let sandbox_path = with_sandbox("search_invalid_regex"); + + let executor = ToolExecutor::new(sandbox_path); + + let call = ToolCall { + tool: ToolName::SearchFiles, + input: serde_json::json!({ + "pattern": "[invalid regex", + "path": "." + }), + }; + let result = executor.execute(call).await; + assert!(result.is_error); + assert!(result.output.contains("Invalid regex")); + + cleanup("search_invalid_regex"); + } + + #[tokio::test] + async fn test_search_files_no_matches() { + let sandbox_path = with_sandbox("search_no_match"); + + tokio::fs::write(sandbox_path.join("file.txt"), "nothing to see") + .await + .expect("create file"); + + let executor = ToolExecutor::new(sandbox_path); + + let call = ToolCall { + tool: ToolName::SearchFiles, + input: serde_json::json!({ + "pattern": "NONEXISTENT_PATTERN_XYZ", + "path": "." + }), + }; + let result = executor.execute(call).await; + assert!(!result.is_error); + assert_eq!(result.output, "No matches found"); + + cleanup("search_no_match"); + } + + // โ”€โ”€ Sensitive File Detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_requires_permission_env_files() { + let executor = ToolExecutor::new(PathBuf::from("/tmp/sandbox")); + + // Should require permission + assert!(executor.requires_permission(".env")); + assert!(executor.requires_permission(".env.local")); + assert!(executor.requires_permission(".env.production")); + assert!(executor.requires_permission("config/.env")); + assert!(executor.requires_permission("config/.env.local")); + } + + #[test] + fn test_requires_permission_crypto_files() { + let executor = ToolExecutor::new(PathBuf::from("/tmp/sandbox")); + + assert!(executor.requires_permission("server.key")); + assert!(executor.requires_permission("certs/server.key")); + assert!(executor.requires_permission("server.pem")); + assert!(executor.requires_permission("certs/server.pem")); + } + + #[test] + fn test_requires_permission_ssh_files() { + let executor = ToolExecutor::new(PathBuf::from("/tmp/sandbox")); + + assert!(executor.requires_permission(".ssh/id_rsa")); + assert!(executor.requires_permission(".ssh/config")); + assert!(executor.requires_permission("home/user/.ssh/authorized_keys")); + } + + #[test] + fn test_does_not_require_permission_for_normal_files() { + let executor = ToolExecutor::new(PathBuf::from("/tmp/sandbox")); + + assert!(!executor.requires_permission("src/main.rs")); + assert!(!executor.requires_permission("package.json")); + assert!(!executor.requires_permission("README.md")); + assert!(!executor.requires_permission("Cargo.toml")); + assert!(!executor.requires_permission("index.html")); + assert!(!executor.requires_permission("data.txt")); + assert!(!executor.requires_permission("config.yaml")); + } + + // โ”€โ”€ Run Command โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[tokio::test] + async fn test_run_command_success() { + let sandbox_path = with_sandbox("run_cmd_success"); + + tokio::fs::write(sandbox_path.join("hello.txt"), "world") + .await + .expect("create file"); + + let executor = ToolExecutor::new(sandbox_path); + + let call = ToolCall { + tool: ToolName::RunCommand, + input: serde_json::json!({ "command": "cat hello.txt" }), + }; + let result = executor.execute(call).await; + assert!(!result.is_error, "command should succeed: {}", result.output); + assert!(result.output.contains("stdout:")); + assert!(result.output.contains("world")); + + cleanup("run_cmd_success"); + } + + #[tokio::test] + async fn test_run_command_missing_field() { + let sandbox_path = with_sandbox("run_cmd_missing"); + + let executor = ToolExecutor::new(sandbox_path); + + let call = ToolCall { + tool: ToolName::RunCommand, + input: serde_json::json!({}), + }; + let result = executor.execute(call).await; + assert!(result.is_error); + assert!(result.output.contains("Missing 'command' field")); + + cleanup("run_cmd_missing"); + } + + #[tokio::test] + async fn test_run_command_invalid_command() { + let sandbox_path = with_sandbox("run_cmd_invalid"); + + let executor = ToolExecutor::new(sandbox_path); + + let call = ToolCall { + tool: ToolName::RunCommand, + input: serde_json::json!({ "command": "nonexistent_command_xyz_12345" }), + }; + let result = executor.execute(call).await; + assert!( + result.is_error, + "invalid command should fail: {}", + result.output + ); + + cleanup("run_cmd_invalid"); + } + + #[tokio::test] + async fn test_run_command_timeout() { + let sandbox_path = with_sandbox("run_cmd_timeout"); + + let mut executor = ToolExecutor::new(sandbox_path); + executor.command_timeout = Duration::from_millis(200); + + let call = ToolCall { + tool: ToolName::RunCommand, + input: serde_json::json!({ "command": "sleep 60" }), + }; + let result = executor.execute(call).await; + assert!( + result.is_error, + "timeout should trigger error: {}", + result.output + ); + assert!(result.output.contains("Command timed out")); + assert!(result.output.contains("60s")); + + cleanup("run_cmd_timeout"); + } + + // โ”€โ”€ validate_path edge cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[tokio::test] + async fn test_validate_path_valid_nested() { + let sandbox_path = with_sandbox("validate_nested"); + + tokio::fs::create_dir_all(sandbox_path.join("a/b/c")) + .await + .unwrap(); + tokio::fs::write(sandbox_path.join("a/b/c/file.txt"), "data") + .await + .unwrap(); + + let executor = ToolExecutor::new(sandbox_path); + + let call = ToolCall { + tool: ToolName::ReadFile, + input: serde_json::json!({ "path": "a/b/c/file.txt" }), + }; + let result = executor.execute(call).await; + assert!(!result.is_error, "should read nested file: {}", result.output); + assert_eq!(result.output, "data"); + + cleanup("validate_nested"); + } + + // โ”€โ”€ normalize_relative edge cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_normalize_relative_curdir() { + let result = ToolExecutor::normalize_relative(Path::new("./src/main.rs")); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Path::new("src/main.rs")); + } + + #[test] + fn test_normalize_relative_leading_dotdot() { + let result = ToolExecutor::normalize_relative(Path::new("../outside")); + assert!(result.is_err()); + } + + #[test] + fn test_normalize_relative_deep_dotdot() { + let result = ToolExecutor::normalize_relative(Path::new("a/b/../../c")); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Path::new("c")); + } + + #[test] + fn test_normalize_relative_normal_only() { + let result = ToolExecutor::normalize_relative(Path::new("src/components/App.tsx")); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Path::new("src/components/App.tsx")); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f8ae985..c968c8c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ } ], "security": { - "csp": null, + "csp": "default-src 'self' blob:; connect-src 'self' https://* http://localhost:*; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'", "assetProtocol": { "enable": true } @@ -27,13 +27,40 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": [ + "deb", + "appimage", + "msi", + "nsis" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "linux": { + "deb": { + "depends": [ + "libwebkit2gtk-4.1-0" + ], + "section": "devel", + "priority": "optional" + } + }, + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDUyNzY4MDdFMzI4RjUwQkMKUldRZnM1L3NMR3dYMFhQb2pZT0ZlK3hZSG9kR1dVZE5lMjVhYU9tZkVtSGN1Qk5hZm5mZjNpU3cK", + "endpoints": [ + "https://github.com/kevinnft/enowX-Coder/releases/latest/download/latest.json" + ] + } } -} +} \ No newline at end of file