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

28 KiB

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:

// 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):

/// 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:

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:

let pm_guard = pm.lock().unwrap();
let frecency_guard = frecency.lock().unwrap();

to:

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
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:

use std::os::unix::fs::PermissionsExt;

In Server::bind(), after the UnixListener::bind(socket_path)?; line, add:

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:

// 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:

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:

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
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:

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:

use std::sync::{Arc, RwLock};

Change the Server struct fields:

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(...)):

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:

fn handle_client(
    stream: UnixStream,
    pm: Arc<RwLock<ProviderManager>>,
    frecency: Arc<RwLock<FrecencyStore>>,
    config: Arc<Config>,
) -> io::Result<()> {

Change handle_request parameter types:

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):

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):

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):

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):

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):

Request::Toggle => Response::Ack,

Submenu (read PM):

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):

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
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>:

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:

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:

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:

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:

#[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:

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>>

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:

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

Update the load_bookmarks method:

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

Update the provider_refresh function:

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
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 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:

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 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:

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:

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:

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:

// 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:

// 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
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"