Files
owlen/crates/owlen-tui/src/model_info_panel.rs
vikingowl 0728262a9e 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>
2025-10-29 12:31:20 +01:00

233 lines
6.7 KiB
Rust

use owlen_core::Theme;
use owlen_core::model::DetailedModelInfo;
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
widgets::{Block, Borders, Paragraph, Wrap},
};
/// Dedicated panel for presenting detailed model information.
pub struct ModelInfoPanel {
info: Option<DetailedModelInfo>,
scroll_offset: usize,
total_lines: usize,
}
impl ModelInfoPanel {
pub fn new() -> Self {
Self {
info: None,
scroll_offset: 0,
total_lines: 0,
}
}
pub fn set_model_info(&mut self, info: DetailedModelInfo) {
self.info = Some(info);
self.scroll_offset = 0;
self.total_lines = 0;
}
pub fn clear(&mut self) {
self.info = None;
self.scroll_offset = 0;
self.total_lines = 0;
}
pub fn render(&mut self, frame: &mut Frame<'_>, area: Rect, theme: &Theme) {
let block = Block::default()
.title("Model Information")
.borders(Borders::ALL)
.style(
Style::default()
.bg(crate::color_convert::to_ratatui_color(&theme.background))
.fg(crate::color_convert::to_ratatui_color(&theme.text)),
)
.border_style(Style::default().fg(crate::color_convert::to_ratatui_color(
&theme.focused_panel_border,
)));
if let Some(info) = &self.info {
let body = self.format_info(info);
self.total_lines = body.lines().count();
let paragraph = Paragraph::new(body)
.block(block)
.style(Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)))
.wrap(Wrap { trim: true })
.scroll((self.scroll_offset as u16, 0));
frame.render_widget(paragraph, area);
} else {
self.total_lines = 0;
let paragraph = Paragraph::new("Select a model to inspect its details.")
.block(block)
.style(
Style::default()
.fg(crate::color_convert::to_ratatui_color(&theme.placeholder))
.add_modifier(Modifier::ITALIC),
)
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
}
}
pub fn scroll_up(&mut self) {
if self.scroll_offset > 0 {
self.scroll_offset -= 1;
}
}
pub fn scroll_down(&mut self, viewport_height: usize) {
if viewport_height == 0 {
return;
}
let max_offset = self.total_lines.saturating_sub(viewport_height);
if self.scroll_offset < max_offset {
self.scroll_offset += 1;
}
}
pub fn reset_scroll(&mut self) {
self.scroll_offset = 0;
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn total_lines(&self) -> usize {
self.total_lines
}
pub fn current_model_name(&self) -> Option<&str> {
self.info.as_ref().map(|info| info.name.as_str())
}
fn format_info(&self, info: &DetailedModelInfo) -> String {
let mut lines = Vec::new();
lines.push(format!("Name: {}", info.name));
lines.push(format!(
"Architecture: {}",
display_option(info.architecture.as_deref())
));
lines.push(format!(
"Parameters: {}",
display_option(info.parameters.as_deref())
));
lines.push(format!(
"Context Length: {}",
display_u64(info.context_length)
));
lines.push(format!(
"Embedding Length: {}",
display_u64(info.embedding_length)
));
lines.push(format!(
"Quantization: {}",
display_option(info.quantization.as_deref())
));
lines.push(format!(
"Family: {}",
display_option(info.family.as_deref())
));
if !info.families.is_empty() {
lines.push(format!("Families: {}", info.families.join(", ")));
}
lines.push(format!(
"Parameter Size: {}",
display_option(info.parameter_size.as_deref())
));
lines.push(format!("Size: {}", format_size(info.size)));
lines.push(format!(
"Modified: {}",
display_option(info.modified_at.as_deref())
));
lines.push(format!(
"License: {}",
display_option(info.license.as_deref())
));
lines.push(format!(
"Digest: {}",
display_option(info.digest.as_deref())
));
if let Some(template) = info.template.as_deref() {
lines.push(format!("Template: {}", snippet(template)));
}
if let Some(system) = info.system.as_deref() {
lines.push(format!("System Prompt: {}", snippet(system)));
}
if let Some(modelfile) = info.modelfile.as_deref() {
lines.push("Modelfile:".to_string());
lines.push(snippet_multiline(modelfile, 8));
}
lines.join("\n")
}
}
impl Default for ModelInfoPanel {
fn default() -> Self {
Self::new()
}
}
fn display_option(value: Option<&str>) -> String {
value
.map(|s| s.to_string())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "N/A".to_string())
}
fn display_u64(value: Option<u64>) -> String {
value
.map(|v| v.to_string())
.unwrap_or_else(|| "N/A".to_string())
}
fn format_size(value: Option<u64>) -> String {
if let Some(bytes) = value {
if bytes >= 1_000_000_000 {
let human = bytes as f64 / 1_000_000_000_f64;
format!("{human:.2} GB ({} bytes)", bytes)
} else if bytes >= 1_000_000 {
let human = bytes as f64 / 1_000_000_f64;
format!("{human:.2} MB ({} bytes)", bytes)
} else if bytes >= 1_000 {
let human = bytes as f64 / 1_000_f64;
format!("{human:.2} KB ({} bytes)", bytes)
} else {
format!("{bytes} bytes")
}
} else {
"N/A".to_string()
}
}
fn snippet(text: &str) -> String {
const MAX_LEN: usize = 160;
if text.len() > MAX_LEN {
format!("{}", text.chars().take(MAX_LEN).collect::<String>())
} else {
text.to_string()
}
}
fn snippet_multiline(text: &str, max_lines: usize) -> String {
let mut lines = Vec::new();
for (idx, line) in text.lines().enumerate() {
if idx >= max_lines {
lines.push("".to_string());
break;
}
lines.push(snippet(line));
}
if lines.is_empty() {
"N/A".to_string()
} else {
lines.join("\n")
}
}