use std::io::Write;
use std::os::unix::net::UnixStream;
use std::time::Duration;
use crate::config::IntegrationConfig;
use crate::protocol::{HostMessage, write_frame};
pub(super) enum HelloOutcome {
Accepted(Vec<String>),
Rejected,
}
pub(super) fn handle_hello(
stream: &mut UnixStream,
config: &IntegrationConfig,
idle_timeout_secs: u64,
protocol_version: u32,
guest_version: String,
container: String,
capabilities: Vec<String>,
) -> anyhow::Result<HelloOutcome> {
if protocol_version != crate::protocol::PROTOCOL_VERSION {
tracing::error!(
"protocol mismatch — got v{}, expected v{}",
protocol_version,
crate::protocol::PROTOCOL_VERSION
);
write_frame(stream, &HostMessage::Shutdown)?;
return Ok(HelloOutcome::Rejected);
}
tracing::info!(
"guest hello (v{}, container: {}, caps: {:?})",
guest_version,
container,
capabilities
);
let mut accepted = Vec::new();
let mut rejected = Vec::new();
for cap in capabilities {
let enabled = match cap.as_str() {
crate::protocol::CAP_NOTIFY => config.notify,
crate::protocol::CAP_XDG_OPEN => config.xdg_open,
crate::protocol::CAP_CLIPBOARD => config.clipboard,
crate::protocol::CAP_HOST_EXEC => config.host_exec.enabled,
_ => false,
};
if enabled {
accepted.push(cap);
} else {
rejected.push(cap);
}
}
let host_exec_shims = if config.host_exec.enabled {
config.host_exec.guest_shims()
} else {
Vec::new()
};
let response = HostMessage::HelloAck {
accepted: accepted.clone(),
rejected,
idle_timeout_secs,
host_exec_shims,
};
write_frame(stream, &response)?;
Ok(HelloOutcome::Accepted(accepted))
}
pub(super) fn handle_notify(
stream: &mut UnixStream,
summary: String,
body: String,
actions: Vec<crate::protocol::NotifyAction>,
) -> anyhow::Result<()> {
if actions.is_empty() {
let _ = notify_rust::Notification::new()
.summary(&summary)
.body(&body)
.show();
} else {
let mut notif = notify_rust::Notification::new();
notif.summary(&summary).body(&body);
for action in &actions {
notif.action(&action.key, &action.label);
}
let handle = match notif.show() {
Ok(h) => h,
Err(_) => {
let _ = write_frame(
stream,
&HostMessage::NotifyActionResult {
notification_id: 0,
action_key: String::new(),
},
);
return Ok(());
}
};
let mut chosen_key = String::new();
handle.wait_for_action(|action| {
chosen_key = action.to_string();
});
let _ = write_frame(
stream,
&HostMessage::NotifyActionResult {
notification_id: 0,
action_key: chosen_key,
},
);
}
Ok(())
}
pub(super) fn handle_xdg_open(uri: String) -> anyhow::Result<()> {
if let Some(validated) = validate_uri(&uri) {
let args = [validated.into()];
let _ =
crate::process::spawn_interactive_timeout("xdg-open", &args, Duration::from_secs(30));
}
Ok(())
}
pub(super) fn handle_clipboard_set(text: String) -> anyhow::Result<()> {
let mut child = std::process::Command::new("wl-copy")
.stdin(std::process::Stdio::piped())
.spawn()?;
if let Some(ref mut stdin) = child.stdin {
let _ = stdin.write_all(text.as_bytes());
}
drop(child.stdin.take());
let _ = crate::process::wait_child_timeout(child, Duration::from_secs(10))?;
Ok(())
}
pub(super) fn handle_clipboard_get(stream: &mut UnixStream) -> anyhow::Result<()> {
let output = std::process::Command::new("wl-paste").output()?;
let text = String::from_utf8_lossy(&output.stdout);
let response = HostMessage::ClipboardData {
text: text.trim().to_string(),
};
write_frame(stream, &response)?;
Ok(())
}
pub(super) fn handle_host_exec(
stream: &mut UnixStream,
config: &IntegrationConfig,
cmd: String,
args: Vec<String>,
) -> anyhow::Result<()> {
if !config.host_exec.enabled {
write_frame(
stream,
&HostMessage::HostExecStderr {
data: "host-exec is disabled".into(),
},
)?;
write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
return Ok(());
}
let entry = match config.host_exec.resolve(&cmd) {
Some(e) => e,
None => {
let allowed = config
.host_exec
.allowlist
.as_ref()
.map(|m| m.keys().cloned().collect::<Vec<_>>().join(", "))
.unwrap_or_default();
write_frame(
stream,
&HostMessage::HostExecStderr {
data: format!(
"Permission denied: '{cmd}' is not in the host-exec allowlist\nAllowed commands: {allowed}"
),
},
)?;
write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
return Ok(());
}
};
if entry.filter_enabled() {
if let Err(msg) = validate_host_exec_args(&args) {
write_frame(
stream,
&HostMessage::HostExecStderr {
data: format!("Security violation: {msg}"),
},
)?;
write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
return Ok(());
}
} else {
tracing::debug!(
"host-exec: security argument filter bypassed for '{cmd}' (filter = false)"
);
}
let resolved = entry.path();
let canonical_path = match std::fs::canonicalize(resolved) {
Ok(p) => p,
Err(e) => {
tracing::error!("host-exec: failed to canonicalize '{}': {e}", resolved);
write_frame(
stream,
&HostMessage::HostExecStderr {
data: format!("Failed to resolve executable path '{resolved}': {e}"),
},
)?;
write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
return Ok(());
}
};
if !canonical_path.is_file() {
write_frame(
stream,
&HostMessage::HostExecStderr {
data: format!("'{}' is not a regular file", canonical_path.display()),
},
)?;
write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
return Ok(());
}
tracing::info!(
"host-exec: resolved '{}' -> {}",
resolved,
canonical_path.display()
);
match std::process::Command::new(&canonical_path)
.args(&args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(child) => {
let output = crate::process::wait_child_timeout(child, Duration::from_mins(1))?;
if !output.stdout.is_empty() {
write_frame(
stream,
&HostMessage::HostExecStdout {
data: String::from_utf8_lossy(&output.stdout).to_string(),
},
)?;
}
if !output.stderr.is_empty() {
write_frame(
stream,
&HostMessage::HostExecStderr {
data: String::from_utf8_lossy(&output.stderr).to_string(),
},
)?;
}
let code = output.status.code().unwrap_or(1);
write_frame(stream, &HostMessage::HostExecDone { exit_code: code })?;
}
Err(e) => {
let msg = if e.kind() == std::io::ErrorKind::NotFound {
format!("host-exec: '{cmd}' not found in allowlist path or host $PATH")
} else {
format!("host-exec: failed to execute '{cmd}': {e}")
};
write_frame(stream, &HostMessage::HostExecStderr { data: msg })?;
write_frame(stream, &HostMessage::HostExecDone { exit_code: 1 })?;
}
}
Ok(())
}
pub(super) fn validate_host_exec_args(args: &[String]) -> Result<(), String> {
for arg in args {
if arg.contains(';')
|| arg.contains('|')
|| arg.contains('&')
|| arg.contains('$')
|| arg.contains('`')
|| arg.contains('\n')
|| arg.contains('\r')
{
return Err(format!("argument {arg:?} contains shell metacharacters"));
}
if arg.contains('<') || arg.contains('>') {
return Err(format!("argument {arg:?} contains redirection operators"));
}
if arg.contains('*')
|| arg.contains('?')
|| arg.contains('[')
|| arg.contains(']')
|| arg.contains('{')
|| arg.contains('}')
{
return Err(format!(
"argument {arg:?} contains glob or brace characters"
));
}
if arg.contains('(') || arg.contains(')') || arg.contains('\\') {
return Err(format!(
"argument {arg:?} contains subshell or escape characters"
));
}
let lower = arg.to_ascii_lowercase();
if lower.starts_with("--exec-path")
|| lower.starts_with("--config")
|| lower.starts_with("--plugin")
|| lower.starts_with("--load")
|| lower.starts_with("--module")
|| lower.starts_with("--remote=")
|| lower == "-o"
{
return Err(format!("argument {arg:?} uses a restricted flag pattern"));
}
}
Ok(())
}
pub(super) fn validate_uri(uri: &str) -> Option<String> {
let s = uri.trim();
if s.is_empty() || s.starts_with('/') || s.starts_with('.') {
return None;
}
match url::Url::parse(s) {
Ok(parsed) => {
let scheme = parsed.scheme().to_ascii_lowercase();
let dangerous_schemes = [
"file",
"javascript",
"data",
"ghelp",
"help",
"info",
"man",
"shell",
"exec",
"run",
"local",
"ssh",
];
if dangerous_schemes.contains(&scheme.as_str()) {
return None;
}
let is_valid_format = scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-');
if is_valid_format {
Some(s.to_string())
} else {
None
}
}
Err(url::ParseError::RelativeUrlWithoutBase) => {
Some(format!("https://{s}"))
}
_ => None,
}
}