Files
owlry/docs/superpowers/plans/2026-03-26-codebase-hardening.md
vikingowl bd69f8eafe perf(ui): use ListBox::remove_all() instead of per-child loop
Replaces five while-loop child removal patterns with the batched
remove_all() method available since GTK 4.12. Avoids per-removal
layout invalidation.
2026-03-29 20:43:41 +02:00

968 lines
28 KiB
Markdown

# Codebase Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 15 soundness, security, robustness, and quality issues across owlry core and owlry-plugins repos.
**Architecture:** Point fixes organized into 5 severity tiers. Each tier is one commit. Core repo (owlry) tiers 1-3 first, then plugins repo (owlry-plugins) tiers 4-5. No new features, no refactoring beyond what each fix requires.
**Tech Stack:** Rust 1.90+, abi_stable 0.11, toml 0.8, dirs 5.0
**Repos:**
- Core: `/home/cnachtigall/ssd/git/archive/owlibou/owlry`
- Plugins: `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`
---
## Task 1: Tier 1 — Critical / Soundness (owlry core)
**Files:**
- Modify: `crates/owlry-plugin-api/src/lib.rs:297-320`
- Modify: `crates/owlry-core/src/server.rs:1-6,91-123,127-215`
### 1a. Replace `static mut HOST_API` with `OnceLock`
- [ ] **Step 1: Replace the static mut and init function**
In `crates/owlry-plugin-api/src/lib.rs`, replace lines 297-320:
```rust
// Old:
// static mut HOST_API: Option<&'static HostAPI> = None;
//
// pub unsafe fn init_host_api(api: &'static HostAPI) {
// unsafe {
// HOST_API = Some(api);
// }
// }
//
// pub fn host_api() -> Option<&'static HostAPI> {
// unsafe { HOST_API }
// }
// New:
use std::sync::OnceLock;
static HOST_API: OnceLock<&'static HostAPI> = OnceLock::new();
/// Initialize the host API (called by the host)
///
/// # Safety
/// Must only be called once by the host before any plugins use the API
pub unsafe fn init_host_api(api: &'static HostAPI) {
let _ = HOST_API.set(api);
}
/// Get the host API
///
/// Returns None if the host hasn't initialized the API yet
pub fn host_api() -> Option<&'static HostAPI> {
HOST_API.get().copied()
}
```
Note: `init_host_api` keeps its `unsafe` signature for API compatibility even though `OnceLock::set` is safe. The `unsafe` documents the caller contract.
- [ ] **Step 2: Verify the plugin-api crate compiles**
Run: `cargo check -p owlry-plugin-api`
Expected: success, no warnings about `static mut`
### 1b. Add IPC message size limit
- [ ] **Step 3: Add size-limited read loop in server.rs**
In `crates/owlry-core/src/server.rs`, add the constant near the top of the file (after the imports):
```rust
/// Maximum size of a single IPC request line (1 MB)
const MAX_REQUEST_SIZE: usize = 1_048_576;
```
Replace the `handle_client` method (lines 91-123). Change the `for line in reader.lines()` loop to a manual `read_line` loop with size checking:
```rust
fn handle_client(
stream: UnixStream,
pm: Arc<Mutex<ProviderManager>>,
frecency: Arc<Mutex<FrecencyStore>>,
config: Arc<Config>,
) -> io::Result<()> {
let reader = BufReader::new(stream.try_clone()?);
let mut writer = stream;
let mut reader = reader;
let mut line = String::new();
loop {
line.clear();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
break; // EOF
}
if line.len() > MAX_REQUEST_SIZE {
let resp = Response::Error {
message: "request too large".to_string(),
};
write_response(&mut writer, &resp)?;
break; // Drop connection
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let request: Request = match serde_json::from_str(trimmed) {
Ok(req) => req,
Err(e) => {
let resp = Response::Error {
message: format!("invalid request JSON: {}", e),
};
write_response(&mut writer, &resp)?;
continue;
}
};
let response = Self::handle_request(&request, &pm, &frecency, &config);
write_response(&mut writer, &response)?;
}
Ok(())
}
```
### 1c. Handle mutex poisoning gracefully
- [ ] **Step 4: Replace all lock().unwrap() in handle_request**
In `crates/owlry-core/src/server.rs`, in the `handle_request` method, replace every occurrence of `.lock().unwrap()` with `.lock().unwrap_or_else(|e| e.into_inner())`. There are instances in the `Query`, `Launch`, `Providers`, `Refresh`, `Submenu`, and `PluginAction` arms.
For example, the Query arm changes from:
```rust
let pm_guard = pm.lock().unwrap();
let frecency_guard = frecency.lock().unwrap();
```
to:
```rust
let pm_guard = pm.lock().unwrap_or_else(|e| e.into_inner());
let frecency_guard = frecency.lock().unwrap_or_else(|e| e.into_inner());
```
Apply this pattern to all `.lock().unwrap()` calls in `handle_request`.
- [ ] **Step 5: Build and test the core crate**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all checks pass, all existing tests pass
- [ ] **Step 6: Commit Tier 1**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
git add crates/owlry-plugin-api/src/lib.rs crates/owlry-core/src/server.rs
git commit -m "fix: soundness — OnceLock for HOST_API, IPC size limits, mutex poisoning recovery"
```
---
## Task 2: Tier 2 — Security (owlry core)
**Files:**
- Modify: `crates/owlry-core/src/server.rs:1-6,29-36,91-123`
- Modify: `crates/owlry-core/src/main.rs:26-32`
### 2a. Set socket permissions after bind
- [ ] **Step 1: Add permission setting in Server::bind**
In `crates/owlry-core/src/server.rs`, add the import at the top:
```rust
use std::os::unix::fs::PermissionsExt;
```
In `Server::bind()`, after the `UnixListener::bind(socket_path)?;` line, add:
```rust
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
```
### 2b. Log signal handler failure
- [ ] **Step 2: Replace .ok() with warning log in main.rs**
In `crates/owlry-core/src/main.rs`, add `use log::warn;` to the imports, then replace lines 26-32:
```rust
// Old:
// ctrlc::set_handler(move || {
// let _ = std::fs::remove_file(&sock_cleanup);
// std::process::exit(0);
// })
// .ok();
// New:
if let Err(e) = ctrlc::set_handler(move || {
let _ = std::fs::remove_file(&sock_cleanup);
std::process::exit(0);
}) {
warn!("Failed to set signal handler: {}", e);
}
```
### 2c. Add client read timeout
- [ ] **Step 3: Set read timeout on accepted connections**
In `crates/owlry-core/src/server.rs`, add `use std::time::Duration;` to the imports.
In the `handle_client` method, at the very top (before the `BufReader` creation), add:
```rust
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
```
This means the `stream` passed to `handle_client` needs to be mutable, or we set it on the clone. Since `set_read_timeout` takes `&self` (not `&mut self`), we can call it directly:
```rust
fn handle_client(
stream: UnixStream,
pm: Arc<...>,
frecency: Arc<...>,
config: Arc<Config>,
) -> io::Result<()> {
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
let reader = BufReader::new(stream.try_clone()?);
// ... rest unchanged
```
- [ ] **Step 4: Build and test**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all checks pass, all existing tests pass
- [ ] **Step 5: Commit Tier 2**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
git add crates/owlry-core/src/server.rs crates/owlry-core/src/main.rs
git commit -m "fix: security — socket perms 0600, signal handler logging, client read timeout"
```
---
## Task 3: Tier 3 — Robustness / Quality (owlry core)
**Files:**
- Modify: `crates/owlry-core/src/server.rs:1-6,17-23,53-73,91-215`
### 3a. Log malformed JSON requests
- [ ] **Step 1: Add warn! for JSON parse errors**
In `crates/owlry-core/src/server.rs`, in the `handle_client` method, in the JSON parse error arm, add a warning log before the error response:
```rust
Err(e) => {
warn!("Malformed request from client: {}", e);
let resp = Response::Error {
message: format!("invalid request JSON: {}", e),
};
write_response(&mut writer, &resp)?;
continue;
}
```
### 3b. Replace Mutex with RwLock
- [ ] **Step 2: Change Server struct and imports**
In `crates/owlry-core/src/server.rs`, change the import from `Mutex` to `RwLock`:
```rust
use std::sync::{Arc, RwLock};
```
Change the `Server` struct fields:
```rust
pub struct Server {
listener: UnixListener,
socket_path: PathBuf,
provider_manager: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
}
```
- [ ] **Step 3: Update Server::bind**
In `Server::bind()`, change `Arc::new(Mutex::new(...))` to `Arc::new(RwLock::new(...))`:
```rust
Ok(Self {
listener,
socket_path: socket_path.to_path_buf(),
provider_manager: Arc::new(RwLock::new(provider_manager)),
frecency: Arc::new(RwLock::new(frecency)),
config: Arc::new(config),
})
```
- [ ] **Step 4: Update handle_client and handle_request signatures**
Change `handle_client` parameter types:
```rust
fn handle_client(
stream: UnixStream,
pm: Arc<RwLock<ProviderManager>>,
frecency: Arc<RwLock<FrecencyStore>>,
config: Arc<Config>,
) -> io::Result<()> {
```
Change `handle_request` parameter types:
```rust
fn handle_request(
request: &Request,
pm: &Arc<RwLock<ProviderManager>>,
frecency: &Arc<RwLock<FrecencyStore>>,
config: &Arc<Config>,
) -> Response {
```
Also update `handle_one_for_testing` if it passes these types through.
- [ ] **Step 5: Update lock calls per request type**
In `handle_request`, change each lock call according to the read/write mapping:
**Query** (read PM, read frecency):
```rust
Request::Query { text, modes } => {
let filter = match modes {
Some(m) => ProviderFilter::from_mode_strings(m),
None => ProviderFilter::all(),
};
let max = config.general.max_results;
let weight = config.providers.frecency_weight;
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
let frecency_guard = frecency.read().unwrap_or_else(|e| e.into_inner());
let results = pm_guard.search_with_frecency(
text, max, &filter, &frecency_guard, weight, None,
);
Response::Results {
items: results
.into_iter()
.map(|(item, score)| launch_item_to_result(item, score))
.collect(),
}
}
```
**Launch** (write frecency):
```rust
Request::Launch { item_id, provider: _ } => {
let mut frecency_guard = frecency.write().unwrap_or_else(|e| e.into_inner());
frecency_guard.record_launch(item_id);
Response::Ack
}
```
**Providers** (read PM):
```rust
Request::Providers => {
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
let descs = pm_guard.available_providers();
Response::Providers {
list: descs.into_iter().map(descriptor_to_desc).collect(),
}
}
```
**Refresh** (write PM):
```rust
Request::Refresh { provider } => {
let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
pm_guard.refresh_provider(provider);
Response::Ack
}
```
**Toggle** (no locks):
```rust
Request::Toggle => Response::Ack,
```
**Submenu** (read PM):
```rust
Request::Submenu { plugin_id, data } => {
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
match pm_guard.query_submenu_actions(plugin_id, data, plugin_id) {
Some((_name, actions)) => Response::SubmenuItems {
items: actions
.into_iter()
.map(|item| launch_item_to_result(item, 0))
.collect(),
},
None => Response::Error {
message: format!("no submenu actions for plugin '{}'", plugin_id),
},
}
}
```
**PluginAction** (read PM):
```rust
Request::PluginAction { command } => {
let pm_guard = pm.read().unwrap_or_else(|e| e.into_inner());
if pm_guard.execute_plugin_action(command) {
Response::Ack
} else {
Response::Error {
message: format!("no plugin handled action '{}'", command),
}
}
}
```
- [ ] **Step 6: Build and test**
Run: `cargo check -p owlry-core && cargo test -p owlry-core`
Expected: all checks pass, all existing tests pass
- [ ] **Step 7: Commit Tier 3**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry
git add crates/owlry-core/src/server.rs
git commit -m "fix: robustness — RwLock for concurrent reads, log malformed JSON requests"
```
---
## Task 4: Tier 4 — Critical fixes (owlry-plugins)
**Files:**
- Modify: `crates/owlry-plugin-converter/src/currency.rs:88-113,244-265`
- Modify: `crates/owlry-plugin-converter/src/units.rs:90-101,160-213`
- Modify: `crates/owlry-plugin-bookmarks/src/lib.rs:40-45,228-260,317-353`
All paths relative to `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`.
### 4a. Fix Box::leak memory leak in converter
- [ ] **Step 1: Change resolve_currency_code return type**
In `crates/owlry-plugin-converter/src/currency.rs`, change the `resolve_currency_code` function (line 88) from returning `Option<String>` to `Option<&'static str>`:
```rust
pub fn resolve_currency_code(alias: &str) -> Option<&'static str> {
let lower = alias.to_lowercase();
// Check aliases
for ca in CURRENCY_ALIASES {
if ca.aliases.contains(&lower.as_str()) {
return Some(ca.code);
}
}
// Check if it's a raw 3-letter ISO code we know about
let upper = alias.to_uppercase();
if upper.len() == 3 {
if upper == "EUR" {
return Some("EUR");
}
// Check if we have rates for it — return the matching alias code
if let Some(rates) = get_rates()
&& rates.rates.contains_key(&upper)
{
// Find a matching CURRENCY_ALIASES entry for this code
for ca in CURRENCY_ALIASES {
if ca.code == upper {
return Some(ca.code);
}
}
// Not in our aliases but valid in ECB rates — we can't return
// a &'static str for an arbitrary code, so skip
}
}
None
}
```
Note: For ISO codes that are in ECB rates but NOT in `CURRENCY_ALIASES`, we lose the ability to resolve them. This is acceptable because:
1. `CURRENCY_ALIASES` already covers the 15 most common currencies
2. The alternative (Box::leak) was leaking memory on every keystroke
- [ ] **Step 2: Update is_currency_alias**
No change needed — it already just calls `resolve_currency_code(alias).is_some()`.
- [ ] **Step 3: Update find_unit in units.rs**
In `crates/owlry-plugin-converter/src/units.rs`, replace lines 90-101:
```rust
pub fn find_unit(alias: &str) -> Option<&'static str> {
let lower = alias.to_lowercase();
if let Some(&i) = ALIAS_MAP.get(&lower) {
return Some(UNITS[i].symbol);
}
// Check currency — resolve_currency_code now returns &'static str directly
currency::resolve_currency_code(&lower)
}
```
- [ ] **Step 4: Update convert_currency in units.rs**
In `crates/owlry-plugin-converter/src/units.rs`, update `convert_currency` (line 160). The `from_code` and `to_code` are now `&'static str`. HashMap lookups with `rates.rates.get(code)` work because `HashMap<String, f64>::get` accepts `&str` via `Borrow`:
```rust
fn convert_currency(value: f64, from: &str, to: &str) -> Option<ConversionResult> {
let rates = currency::get_rates()?;
let from_code = currency::resolve_currency_code(from)?;
let to_code = currency::resolve_currency_code(to)?;
let from_rate = if from_code == "EUR" {
1.0
} else {
*rates.rates.get(from_code)?
};
let to_rate = if to_code == "EUR" {
1.0
} else {
*rates.rates.get(to_code)?
};
let result = value / from_rate * to_rate;
Some(format_currency_result(result, to_code))
}
```
- [ ] **Step 5: Update convert_currency_common in units.rs**
In `crates/owlry-plugin-converter/src/units.rs`, update `convert_currency_common` (line 180). Change `from_code` handling:
```rust
fn convert_currency_common(value: f64, from: &str) -> Vec<ConversionResult> {
let rates = match currency::get_rates() {
Some(r) => r,
None => return vec![],
};
let from_code = match currency::resolve_currency_code(from) {
Some(c) => c,
None => return vec![],
};
let targets = COMMON_TARGETS.get(&Category::Currency).unwrap();
let from_rate = if from_code == "EUR" {
1.0
} else {
match rates.rates.get(from_code) {
Some(&r) => r,
None => return vec![],
}
};
targets
.iter()
.filter(|&&sym| sym != from_code)
.filter_map(|&sym| {
let to_rate = if sym == "EUR" {
1.0
} else {
*rates.rates.get(sym)?
};
let result = value / from_rate * to_rate;
Some(format_currency_result(result, sym))
})
.take(5)
.collect()
}
```
- [ ] **Step 6: Update currency tests**
In `crates/owlry-plugin-converter/src/currency.rs`, update test assertions to use `&str` instead of `String`:
```rust
#[test]
fn test_resolve_currency_code_iso() {
assert_eq!(resolve_currency_code("usd"), Some("USD"));
assert_eq!(resolve_currency_code("EUR"), Some("EUR"));
}
#[test]
fn test_resolve_currency_code_name() {
assert_eq!(resolve_currency_code("dollar"), Some("USD"));
assert_eq!(resolve_currency_code("euro"), Some("EUR"));
assert_eq!(resolve_currency_code("pounds"), Some("GBP"));
}
#[test]
fn test_resolve_currency_code_symbol() {
assert_eq!(resolve_currency_code("$"), Some("USD"));
assert_eq!(resolve_currency_code(""), Some("EUR"));
assert_eq!(resolve_currency_code("£"), Some("GBP"));
}
#[test]
fn test_resolve_currency_unknown() {
assert_eq!(resolve_currency_code("xyz"), None);
}
```
### 4b. Fix bookmarks temp file race condition
- [ ] **Step 7: Use PID-based temp filenames**
In `crates/owlry-plugin-bookmarks/src/lib.rs`, replace the `read_firefox_bookmarks` method. Change lines 318-319 and the corresponding favicons temp path:
```rust
fn read_firefox_bookmarks(places_path: &PathBuf, items: &mut Vec<PluginItem>) {
let temp_dir = std::env::temp_dir();
let pid = std::process::id();
let temp_db = temp_dir.join(format!("owlry_places_{}.sqlite", pid));
// Copy database to temp location to avoid locking issues
if fs::copy(places_path, &temp_db).is_err() {
return;
}
// Also copy WAL file if it exists
let wal_path = places_path.with_extension("sqlite-wal");
if wal_path.exists() {
let temp_wal = temp_db.with_extension("sqlite-wal");
let _ = fs::copy(&wal_path, &temp_wal);
}
// Copy favicons database if available
let favicons_path = Self::firefox_favicons_path(places_path);
let temp_favicons = temp_dir.join(format!("owlry_favicons_{}.sqlite", pid));
if let Some(ref fp) = favicons_path {
let _ = fs::copy(fp, &temp_favicons);
let fav_wal = fp.with_extension("sqlite-wal");
if fav_wal.exists() {
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
}
}
let cache_dir = Self::ensure_favicon_cache_dir();
// Read bookmarks from places.sqlite
let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref());
// Clean up temp files
let _ = fs::remove_file(&temp_db);
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
let _ = fs::remove_file(&temp_favicons);
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
// ... rest of method unchanged (the for loop adding items)
```
### 4c. Fix bookmarks background refresh never updating state
- [ ] **Step 8: Change BookmarksState to use Arc<Mutex<Vec<PluginItem>>>**
In `crates/owlry-plugin-bookmarks/src/lib.rs`, add `use std::sync::Mutex;` to imports (it's already importing `Arc` and `AtomicBool`).
Change the struct:
```rust
struct BookmarksState {
/// Cached bookmark items (shared with background thread)
items: Arc<Mutex<Vec<PluginItem>>>,
/// Flag to prevent concurrent background loads
loading: Arc<AtomicBool>,
}
impl BookmarksState {
fn new() -> Self {
Self {
items: Arc::new(Mutex::new(Vec::new())),
loading: Arc::new(AtomicBool::new(false)),
}
}
```
- [ ] **Step 9: Update load_bookmarks to write through Arc<Mutex>**
Update the `load_bookmarks` method:
```rust
fn load_bookmarks(&self) {
// Fast path: load from cache immediately if items are empty
{
let mut items = self.items.lock().unwrap_or_else(|e| e.into_inner());
if items.is_empty() {
*items = Self::load_cached_bookmarks();
}
}
// Don't start another background load if one is already running
if self.loading.swap(true, Ordering::SeqCst) {
return;
}
// Spawn background thread to refresh bookmarks
let loading = self.loading.clone();
let items_ref = self.items.clone();
thread::spawn(move || {
let mut new_items = Vec::new();
// Load Chrome/Chromium bookmarks (fast - just JSON parsing)
for path in Self::chromium_bookmark_paths() {
if path.exists() {
Self::read_chrome_bookmarks_static(&path, &mut new_items);
}
}
// Load Firefox bookmarks with favicons (synchronous with rusqlite)
for path in Self::firefox_places_paths() {
Self::read_firefox_bookmarks(&path, &mut new_items);
}
// Save to cache for next startup
Self::save_cached_bookmarks(&new_items);
// Update shared state so next refresh returns fresh data
if let Ok(mut items) = items_ref.lock() {
*items = new_items;
}
loading.store(false, Ordering::SeqCst);
});
}
```
Note: `load_bookmarks` now takes `&self` instead of `&mut self`.
- [ ] **Step 10: Update provider_refresh to read from Arc<Mutex>**
Update the `provider_refresh` function:
```rust
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<BookmarksState>
let state = unsafe { &*(handle.ptr as *const BookmarksState) };
// Load bookmarks
state.load_bookmarks();
// Return items
let items = state.items.lock().unwrap_or_else(|e| e.into_inner());
items.to_vec().into()
}
```
Note: Uses `&*` (shared ref) instead of `&mut *` since `load_bookmarks` now takes `&self`.
- [ ] **Step 11: Build and test plugins**
Run: `cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins && cargo check && cargo test`
Expected: all checks pass, all existing tests pass
- [ ] **Step 12: Commit Tier 4**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
git add crates/owlry-plugin-converter/src/currency.rs crates/owlry-plugin-converter/src/units.rs crates/owlry-plugin-bookmarks/src/lib.rs
git commit -m "fix: critical — eliminate Box::leak in converter, secure temp files, fix background refresh"
```
---
## Task 5: Tier 5 — Quality fixes (owlry-plugins)
**Files:**
- Modify: `crates/owlry-plugin-ssh/Cargo.toml`
- Modify: `crates/owlry-plugin-ssh/src/lib.rs:17-48`
- Modify: `crates/owlry-plugin-websearch/Cargo.toml`
- Modify: `crates/owlry-plugin-websearch/src/lib.rs:46-76,174-177`
- Modify: `crates/owlry-plugin-emoji/src/lib.rs:34-37,463-481`
- Modify: `crates/owlry-plugin-calculator/src/lib.rs:139`
- Modify: `crates/owlry-plugin-converter/src/lib.rs:95`
All paths relative to `/home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins`.
### 5a. SSH plugin: read terminal from config
- [ ] **Step 1: Add toml dependency to SSH plugin**
In `crates/owlry-plugin-ssh/Cargo.toml`, add:
```toml
# TOML config parsing
toml = "0.8"
```
- [ ] **Step 2: Add config loading and update SshState::new**
In `crates/owlry-plugin-ssh/src/lib.rs`, add `use std::fs;` to imports, remove the `DEFAULT_TERMINAL` constant, and update `SshState::new`:
```rust
impl SshState {
fn new() -> Self {
let terminal = Self::load_terminal_from_config();
Self {
items: Vec::new(),
terminal_command: terminal,
}
}
fn load_terminal_from_config() -> String {
// Try [plugins.ssh] in config.toml
let config_path = dirs::config_dir().map(|d| d.join("owlry").join("config.toml"));
if let Some(content) = config_path.and_then(|p| fs::read_to_string(p).ok())
&& let Ok(toml) = content.parse::<toml::Table>()
{
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(ssh) = plugins.get("ssh").and_then(|v| v.as_table())
&& let Some(terminal) = ssh.get("terminal").and_then(|v| v.as_str())
{
return terminal.to_string();
}
}
// Fall back to $TERMINAL env var
if let Ok(terminal) = std::env::var("TERMINAL") {
return terminal;
}
// Last resort
"xdg-terminal-exec".to_string()
}
```
### 5b. WebSearch plugin: read engine from config
- [ ] **Step 3: Add dependencies to websearch plugin**
In `crates/owlry-plugin-websearch/Cargo.toml`, add:
```toml
# TOML config parsing
toml = "0.8"
# XDG directories for config
dirs = "5.0"
```
- [ ] **Step 4: Add config loading and update provider_init**
In `crates/owlry-plugin-websearch/src/lib.rs`, add `use std::fs;` to imports. Add a config loading function and update `provider_init`:
```rust
fn load_engine_from_config() -> String {
let config_path = dirs::config_dir().map(|d| d.join("owlry").join("config.toml"));
if let Some(content) = config_path.and_then(|p| fs::read_to_string(p).ok())
&& let Ok(toml) = content.parse::<toml::Table>()
{
if let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(websearch) = plugins.get("websearch").and_then(|v| v.as_table())
&& let Some(engine) = websearch.get("engine").and_then(|v| v.as_str())
{
return engine.to_string();
}
}
DEFAULT_ENGINE.to_string()
}
extern "C" fn provider_init(_provider_id: RStr<'_>) -> ProviderHandle {
let engine = load_engine_from_config();
let state = Box::new(WebSearchState::with_engine(&engine));
ProviderHandle::from_box(state)
}
```
Remove the TODO comment from the old `provider_init`.
### 5c. Emoji plugin: build items once at init
- [ ] **Step 5: Move load_emojis to constructor**
In `crates/owlry-plugin-emoji/src/lib.rs`, change `EmojiState::new` to call `load_emojis`:
```rust
impl EmojiState {
fn new() -> Self {
let mut state = Self { items: Vec::new() };
state.load_emojis();
state
}
```
Update `provider_refresh` to just return the cached items without reloading:
```rust
extern "C" fn provider_refresh(handle: ProviderHandle) -> RVec<PluginItem> {
if handle.ptr.is_null() {
return RVec::new();
}
// SAFETY: We created this handle from Box<EmojiState>
let state = unsafe { &*(handle.ptr as *const EmojiState) };
// Return cached items (loaded once at init)
state.items.to_vec().into()
}
```
Note: Uses `&*` (shared ref) since we're only reading.
### 5d. Calculator/Converter: safer shell commands
- [ ] **Step 6: Fix calculator command**
In `crates/owlry-plugin-calculator/src/lib.rs`, in `evaluate_expression` (around line 139), replace:
```rust
// Old:
format!("sh -c 'echo -n \"{}\" | wl-copy'", result_str)
// New:
format!("printf '%s' '{}' | wl-copy", result_str.replace('\'', "'\\''"))
```
- [ ] **Step 7: Fix converter command**
In `crates/owlry-plugin-converter/src/lib.rs`, in `provider_query` (around line 95), replace:
```rust
// Old:
format!("sh -c 'echo -n \"{}\" | wl-copy'", r.raw_value)
// New:
format!("printf '%s' '{}' | wl-copy", r.raw_value.replace('\'', "'\\''"))
```
- [ ] **Step 8: Build and test all plugins**
Run: `cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins && cargo check && cargo test`
Expected: all checks pass, all existing tests pass
- [ ] **Step 9: Commit Tier 5**
```bash
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
git add crates/owlry-plugin-ssh/Cargo.toml crates/owlry-plugin-ssh/src/lib.rs \
crates/owlry-plugin-websearch/Cargo.toml crates/owlry-plugin-websearch/src/lib.rs \
crates/owlry-plugin-emoji/src/lib.rs \
crates/owlry-plugin-calculator/src/lib.rs \
crates/owlry-plugin-converter/src/lib.rs
git commit -m "fix: quality — config-based terminal/engine, emoji init perf, safer shell commands"
```