fix(core,mcp,security)!: resolve critical P0/P1 issues
BREAKING CHANGES: - owlen-core no longer depends on ratatui/crossterm - RemoteMcpClient constructors are now async - MCP path validation is stricter (security hardening) This commit resolves three critical issues identified in project analysis: ## P0-1: Extract TUI dependencies from owlen-core Create owlen-ui-common crate to hold UI-agnostic color and theme abstractions, removing architectural boundary violation. Changes: - Create new owlen-ui-common crate with abstract Color enum - Move theme.rs from owlen-core to owlen-ui-common - Define Color with Rgb and Named variants (no ratatui dependency) - Create color conversion layer in owlen-tui (color_convert.rs) - Update 35+ color usages with conversion wrappers - Remove ratatui/crossterm from owlen-core dependencies Benefits: - owlen-core usable in headless/CLI contexts - Enables future GUI frontends - Reduces binary size for core library consumers ## P0-2: Fix blocking WebSocket connections Convert RemoteMcpClient constructors to async, eliminating runtime blocking that froze TUI for 30+ seconds on slow connections. Changes: - Make new_with_runtime(), new_with_config(), new() async - Remove block_in_place wrappers for I/O operations - Add 30-second connection timeout with tokio::time::timeout - Update 15+ call sites across 10 files to await constructors - Convert 4 test functions to #[tokio::test] Benefits: - TUI remains responsive during WebSocket connections - Proper async I/O follows Rust best practices - No more indefinite hangs ## P1-1: Secure path traversal vulnerabilities Implement comprehensive path validation with 7 defense layers to prevent file access outside workspace boundaries. Changes: - Create validate_safe_path() with multi-layer security: * URL decoding (prevents %2E%2E bypasses) * Absolute path rejection * Null byte protection * Windows-specific checks (UNC/device paths) * Lexical path cleaning (removes .. components) * Symlink resolution via canonicalization * Boundary verification with starts_with check - Update 4 MCP resource functions (get/list/write/delete) - Add 11 comprehensive security tests Benefits: - Blocks URL-encoded, absolute, UNC path attacks - Prevents null byte injection - Stops symlink escape attempts - Cross-platform security (Windows/Linux/macOS) ## Test Results - owlen-core: 109/109 tests pass (100%) - owlen-tui: 52/53 tests pass (98%, 1 pre-existing failure) - owlen-providers: 2/2 tests pass (100%) - Build: cargo build --all succeeds ## Verification - ✓ cargo tree -p owlen-core shows no TUI dependencies - ✓ No block_in_place calls remain in MCP I/O code - ✓ All 11 security tests pass Fixes: #P0-1, #P0-2, #P1-1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
18
crates/owlen-ui-common/Cargo.toml
Normal file
18
crates/owlen-ui-common/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "owlen-ui-common"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "UI-agnostic color and theme abstractions for OWLEN"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
shellexpand = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
206
crates/owlen-ui-common/src/color.rs
Normal file
206
crates/owlen-ui-common/src/color.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
//! UI-agnostic color abstraction for OWLEN
|
||||
//!
|
||||
//! This module provides a color type that can be used across different UI
|
||||
//! implementations (TUI, GUI, etc.) without tying the core library to any
|
||||
//! specific rendering framework.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An abstract color representation that can be converted to different UI frameworks
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Color {
|
||||
/// RGB color with red, green, and blue components (0-255)
|
||||
Rgb(u8, u8, u8),
|
||||
/// Named ANSI color
|
||||
Named(NamedColor),
|
||||
}
|
||||
|
||||
/// Standard ANSI color names
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NamedColor {
|
||||
Black,
|
||||
Red,
|
||||
Green,
|
||||
Yellow,
|
||||
Blue,
|
||||
Magenta,
|
||||
Cyan,
|
||||
Gray,
|
||||
DarkGray,
|
||||
LightRed,
|
||||
LightGreen,
|
||||
LightYellow,
|
||||
LightBlue,
|
||||
LightMagenta,
|
||||
LightCyan,
|
||||
White,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
/// Create an RGB color
|
||||
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// Create a named color
|
||||
pub const fn named(color: NamedColor) -> Self {
|
||||
Color::Named(color)
|
||||
}
|
||||
|
||||
/// Convenience constructors for common colors
|
||||
pub const fn black() -> Self {
|
||||
Color::Named(NamedColor::Black)
|
||||
}
|
||||
|
||||
pub const fn white() -> Self {
|
||||
Color::Named(NamedColor::White)
|
||||
}
|
||||
|
||||
pub const fn red() -> Self {
|
||||
Color::Named(NamedColor::Red)
|
||||
}
|
||||
|
||||
pub const fn green() -> Self {
|
||||
Color::Named(NamedColor::Green)
|
||||
}
|
||||
|
||||
pub const fn yellow() -> Self {
|
||||
Color::Named(NamedColor::Yellow)
|
||||
}
|
||||
|
||||
pub const fn blue() -> Self {
|
||||
Color::Named(NamedColor::Blue)
|
||||
}
|
||||
|
||||
pub const fn magenta() -> Self {
|
||||
Color::Named(NamedColor::Magenta)
|
||||
}
|
||||
|
||||
pub const fn cyan() -> Self {
|
||||
Color::Named(NamedColor::Cyan)
|
||||
}
|
||||
|
||||
pub const fn gray() -> Self {
|
||||
Color::Named(NamedColor::Gray)
|
||||
}
|
||||
|
||||
pub const fn dark_gray() -> Self {
|
||||
Color::Named(NamedColor::DarkGray)
|
||||
}
|
||||
|
||||
pub const fn light_red() -> Self {
|
||||
Color::Named(NamedColor::LightRed)
|
||||
}
|
||||
|
||||
pub const fn light_green() -> Self {
|
||||
Color::Named(NamedColor::LightGreen)
|
||||
}
|
||||
|
||||
pub const fn light_yellow() -> Self {
|
||||
Color::Named(NamedColor::LightYellow)
|
||||
}
|
||||
|
||||
pub const fn light_blue() -> Self {
|
||||
Color::Named(NamedColor::LightBlue)
|
||||
}
|
||||
|
||||
pub const fn light_magenta() -> Self {
|
||||
Color::Named(NamedColor::LightMagenta)
|
||||
}
|
||||
|
||||
pub const fn light_cyan() -> Self {
|
||||
Color::Named(NamedColor::LightCyan)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a color from a string representation
|
||||
///
|
||||
/// Supports:
|
||||
/// - Hex colors: "#ff0000", "#FF0000"
|
||||
/// - Named colors: "red", "lightblue", etc.
|
||||
pub fn parse_color(s: &str) -> Result<Color, String> {
|
||||
if let Some(hex) = s.strip_prefix('#')
|
||||
&& hex.len() == 6
|
||||
{
|
||||
let r =
|
||||
u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
|
||||
let g =
|
||||
u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
|
||||
let b =
|
||||
u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
|
||||
return Ok(Color::Rgb(r, g, b));
|
||||
}
|
||||
|
||||
// Try named colors
|
||||
match s.to_lowercase().as_str() {
|
||||
"black" => Ok(Color::Named(NamedColor::Black)),
|
||||
"red" => Ok(Color::Named(NamedColor::Red)),
|
||||
"green" => Ok(Color::Named(NamedColor::Green)),
|
||||
"yellow" => Ok(Color::Named(NamedColor::Yellow)),
|
||||
"blue" => Ok(Color::Named(NamedColor::Blue)),
|
||||
"magenta" => Ok(Color::Named(NamedColor::Magenta)),
|
||||
"cyan" => Ok(Color::Named(NamedColor::Cyan)),
|
||||
"gray" | "grey" => Ok(Color::Named(NamedColor::Gray)),
|
||||
"darkgray" | "darkgrey" => Ok(Color::Named(NamedColor::DarkGray)),
|
||||
"lightred" => Ok(Color::Named(NamedColor::LightRed)),
|
||||
"lightgreen" => Ok(Color::Named(NamedColor::LightGreen)),
|
||||
"lightyellow" => Ok(Color::Named(NamedColor::LightYellow)),
|
||||
"lightblue" => Ok(Color::Named(NamedColor::LightBlue)),
|
||||
"lightmagenta" => Ok(Color::Named(NamedColor::LightMagenta)),
|
||||
"lightcyan" => Ok(Color::Named(NamedColor::LightCyan)),
|
||||
"white" => Ok(Color::Named(NamedColor::White)),
|
||||
_ => Err(format!("Unknown color: {}", s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a color to its string representation
|
||||
pub fn color_to_string(color: &Color) -> String {
|
||||
match color {
|
||||
Color::Named(NamedColor::Black) => "black".to_string(),
|
||||
Color::Named(NamedColor::Red) => "red".to_string(),
|
||||
Color::Named(NamedColor::Green) => "green".to_string(),
|
||||
Color::Named(NamedColor::Yellow) => "yellow".to_string(),
|
||||
Color::Named(NamedColor::Blue) => "blue".to_string(),
|
||||
Color::Named(NamedColor::Magenta) => "magenta".to_string(),
|
||||
Color::Named(NamedColor::Cyan) => "cyan".to_string(),
|
||||
Color::Named(NamedColor::Gray) => "gray".to_string(),
|
||||
Color::Named(NamedColor::DarkGray) => "darkgray".to_string(),
|
||||
Color::Named(NamedColor::LightRed) => "lightred".to_string(),
|
||||
Color::Named(NamedColor::LightGreen) => "lightgreen".to_string(),
|
||||
Color::Named(NamedColor::LightYellow) => "lightyellow".to_string(),
|
||||
Color::Named(NamedColor::LightBlue) => "lightblue".to_string(),
|
||||
Color::Named(NamedColor::LightMagenta) => "lightmagenta".to_string(),
|
||||
Color::Named(NamedColor::LightCyan) => "lightcyan".to_string(),
|
||||
Color::Named(NamedColor::White) => "white".to_string(),
|
||||
Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_color_parsing() {
|
||||
assert_eq!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("red"), Ok(Color::Named(NamedColor::Red)));
|
||||
assert_eq!(
|
||||
parse_color("lightblue"),
|
||||
Ok(Color::Named(NamedColor::LightBlue))
|
||||
);
|
||||
assert!(parse_color("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_color_to_string() {
|
||||
assert_eq!(color_to_string(&Color::Named(NamedColor::Red)), "red");
|
||||
assert_eq!(color_to_string(&Color::Rgb(255, 0, 0)), "#ff0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_color_constructors() {
|
||||
assert_eq!(Color::black(), Color::Named(NamedColor::Black));
|
||||
assert_eq!(Color::rgb(255, 0, 0), Color::Rgb(255, 0, 0));
|
||||
}
|
||||
}
|
||||
14
crates/owlen-ui-common/src/lib.rs
Normal file
14
crates/owlen-ui-common/src/lib.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
//! UI-agnostic abstractions for OWLEN
|
||||
//!
|
||||
//! This crate provides color and theme abstractions that can be used across
|
||||
//! different UI implementations (TUI, GUI, etc.) without tying the core library
|
||||
//! to any specific rendering framework.
|
||||
|
||||
pub mod color;
|
||||
pub mod theme;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use color::{Color, NamedColor, color_to_string, parse_color};
|
||||
pub use theme::{
|
||||
Theme, ThemePalette, built_in_themes, default_themes_dir, get_theme, load_all_themes,
|
||||
};
|
||||
1175
crates/owlen-ui-common/src/theme.rs
Normal file
1175
crates/owlen-ui-common/src/theme.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user