use bext_plugin_api::types::SandboxPermissions;
use rquickjs::{Ctx, Function, Object, Result as JsResult};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
pub(crate) struct HostBridge {
pub plugin_id: String,
pub permissions: SandboxPermissions,
pub storage_dir: PathBuf,
pub config: serde_json::Value,
pub fetch_limiter: Mutex<FetchLimiter>,
pub storage_bytes: Mutex<u64>,
}
pub(crate) struct FetchLimiter {
tokens: u32,
max_tokens: u32,
last_refill: std::time::Instant,
}
impl FetchLimiter {
pub fn new(max_per_minute: u32) -> Self {
Self {
tokens: max_per_minute,
max_tokens: max_per_minute,
last_refill: std::time::Instant::now(),
}
}
pub fn try_acquire(&mut self) -> bool {
let elapsed = self.last_refill.elapsed();
if elapsed >= std::time::Duration::from_secs(60) {
self.tokens = self.max_tokens;
self.last_refill = std::time::Instant::now();
}
if self.tokens > 0 {
self.tokens -= 1;
true
} else {
false
}
}
}
impl HostBridge {
pub fn new(
plugin_id: String,
permissions: SandboxPermissions,
storage_root: &std::path::Path,
config: serde_json::Value,
) -> Self {
let storage_dir = storage_root.join(&plugin_id);
Self {
plugin_id,
permissions: permissions.clone(),
storage_dir,
config,
fetch_limiter: Mutex::new(FetchLimiter::new(permissions.max_fetch_per_minute)),
storage_bytes: Mutex::new(0),
}
}
fn is_url_allowed(&self, url: &str) -> bool {
if self.permissions.allowed_urls.is_empty() {
return false;
}
self.permissions
.allowed_urls
.iter()
.any(|p| glob_match(p, url))
}
fn try_fetch(&self) -> bool {
self.fetch_limiter
.lock()
.unwrap_or_else(|e| e.into_inner())
.try_acquire()
}
fn check_storage_quota(&self, additional: u64) -> bool {
let current = self.storage_bytes.lock().unwrap_or_else(|e| e.into_inner());
*current + additional <= self.permissions.storage_quota_kb * 1024
}
fn record_storage(&self, bytes: u64) {
let mut current = self.storage_bytes.lock().unwrap_or_else(|e| e.into_inner());
*current += bytes;
}
fn sanitize_key(key: &str) -> Option<&str> {
if key.contains("..") || key.contains('/') || key.contains('\\') || key.contains('\0') {
None
} else {
Some(key)
}
}
}
pub(crate) fn register_globals(ctx: &Ctx<'_>, bridge: Arc<HostBridge>) -> JsResult<()> {
let globals = ctx.globals();
let console = Object::new(ctx.clone())?;
{
let id = bridge.plugin_id.clone();
console.set(
"log",
Function::new(ctx.clone(), move |msg: String| {
tracing::info!(plugin = %id, "{}", msg);
}),
)?;
}
{
let id = bridge.plugin_id.clone();
console.set(
"warn",
Function::new(ctx.clone(), move |msg: String| {
tracing::warn!(plugin = %id, "{}", msg);
}),
)?;
}
{
let id = bridge.plugin_id.clone();
console.set(
"error",
Function::new(ctx.clone(), move |msg: String| {
tracing::error!(plugin = %id, "{}", msg);
}),
)?;
}
{
let id = bridge.plugin_id.clone();
console.set(
"info",
Function::new(ctx.clone(), move |msg: String| {
tracing::info!(plugin = %id, "{}", msg);
}),
)?;
}
{
let id = bridge.plugin_id.clone();
console.set(
"debug",
Function::new(ctx.clone(), move |msg: String| {
tracing::debug!(plugin = %id, "{}", msg);
}),
)?;
}
globals.set("console", console)?;
let bext = Object::new(ctx.clone())?;
{
let config_str = bridge.config.to_string();
let config_val: rquickjs::Value = ctx.json_parse(config_str)?;
bext.set("config", config_val)?;
}
let storage = Object::new(ctx.clone())?;
{
let b = bridge.clone();
storage.set(
"get",
Function::new(
ctx.clone(),
move |key: String| -> rquickjs::Result<Option<String>> {
let key = HostBridge::sanitize_key(&key).ok_or_else(|| {
rquickjs::Error::new_from_js("string", "invalid storage key")
})?;
let path = b.storage_dir.join(key);
match std::fs::read_to_string(&path) {
Ok(val) => Ok(Some(val)),
Err(_) => Ok(None),
}
},
),
)?;
}
{
let b = bridge.clone();
storage.set(
"set",
Function::new(
ctx.clone(),
move |key: String, value: String| -> rquickjs::Result<bool> {
let key = HostBridge::sanitize_key(&key).ok_or_else(|| {
rquickjs::Error::new_from_js("string", "invalid storage key")
})?;
let bytes = value.len() as u64;
if !b.check_storage_quota(bytes) {
return Ok(false);
}
let _ = std::fs::create_dir_all(&b.storage_dir);
match std::fs::write(b.storage_dir.join(key), value.as_bytes()) {
Ok(()) => {
b.record_storage(bytes);
Ok(true)
}
Err(_) => Ok(false),
}
},
),
)?;
}
{
let b = bridge.clone();
storage.set(
"delete",
Function::new(ctx.clone(), move |key: String| -> rquickjs::Result<bool> {
let key = HostBridge::sanitize_key(&key)
.ok_or_else(|| rquickjs::Error::new_from_js("string", "invalid storage key"))?;
Ok(std::fs::remove_file(b.storage_dir.join(key)).is_ok())
}),
)?;
}
bext.set("storage", storage)?;
{
const MAX_RESPONSE_BYTES: u64 = 1_048_576;
let b = bridge.clone();
bext.set("fetch", Function::new(ctx.clone(), move |url: String, method: Option<String>, body: Option<String>| -> rquickjs::Result<String> {
if is_private_url(&url) {
return Err(rquickjs::Error::new_from_js("string", "blocked: private/internal URL"));
}
if !b.is_url_allowed(&url) {
return Err(rquickjs::Error::new_from_js("string", "URL not in allowlist"));
}
if !b.try_fetch() {
return Err(rquickjs::Error::new_from_js("string", "rate limit exceeded"));
}
let method = method.unwrap_or_else(|| "GET".into());
let request = match method.to_uppercase().as_str() {
"GET" => ureq::get(&url),
"POST" => ureq::post(&url),
"PUT" => ureq::put(&url),
"DELETE" => ureq::delete(&url),
"PATCH" => ureq::patch(&url),
"HEAD" => ureq::head(&url),
_ => return Err(rquickjs::Error::new_from_js("string", "unsupported method")),
}
.timeout(std::time::Duration::from_secs(5));
let response = if let Some(ref b) = body {
request.send_string(b)
} else {
request.call()
};
let read_body_limited = |resp: ureq::Response| -> std::result::Result<String, String> {
use std::io::Read;
let mut reader = resp.into_reader().take(MAX_RESPONSE_BYTES + 1);
let mut buf = Vec::new();
match reader.read_to_end(&mut buf) {
Ok(_) => {
if buf.len() as u64 > MAX_RESPONSE_BYTES {
return Err(format!(
"response body exceeds {} byte limit",
MAX_RESPONSE_BYTES
));
}
Ok(String::from_utf8(buf)
.unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).to_string()))
}
Err(_) => Ok(String::new()),
}
};
match response {
Ok(resp) => {
let status = resp.status();
let resp_body = match read_body_limited(resp) {
Ok(body) => body,
Err(e) => {
tracing::warn!(error = %e, "fetch response body error");
return Err(rquickjs::Error::new_from_js("string", "response body too large or unreadable"));
}
};
Ok(serde_json::json!({"status": status, "body": resp_body}).to_string())
}
Err(ureq::Error::Status(code, resp)) => {
let resp_body = match read_body_limited(resp) {
Ok(body) => body,
Err(e) => {
tracing::warn!(error = %e, "fetch response body error");
return Err(rquickjs::Error::new_from_js("string", "response body too large or unreadable"));
}
};
Ok(serde_json::json!({"status": code, "body": resp_body}).to_string())
}
Err(e) => {
tracing::warn!(plugin = %b.plugin_id, url = %url, error = %e, "fetch failed");
Err(rquickjs::Error::new_from_js("string", "fetch request failed"))
}
}
}))?;
}
{
let b = bridge.clone();
bext.set(
"_metricImpl",
Function::new(
ctx.clone(),
move |name: String, value: f64, tags: String| {
tracing::info!(
target: "bext::plugin_metric",
plugin = %b.plugin_id,
metric = %name,
value = value,
tags = %tags,
"plugin_metric"
);
},
),
)?;
}
globals.set("bext", bext)?;
ctx.eval::<(), _>(
b"bext.metric = function(name, value, tags) { bext._metricImpl(name, value, tags || '{}'); };"
)?;
Ok(())
}
fn glob_match(pattern: &str, input: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == input;
}
let mut pos = 0;
if !parts[0].is_empty() {
if !input.starts_with(parts[0]) {
return false;
}
pos = parts[0].len();
}
for part in &parts[1..parts.len() - 1] {
if part.is_empty() {
continue;
}
match input[pos..].find(part) {
Some(idx) => pos += idx + part.len(),
None => return false,
}
}
let last = parts[parts.len() - 1];
if !last.is_empty() {
input[pos..].ends_with(last)
} else {
true
}
}
fn is_private_url(url_str: &str) -> bool {
let parsed = match url::Url::parse(url_str) {
Ok(u) => u,
Err(_) => return true, };
let host = match parsed.host_str() {
Some(h) => h,
None => return true,
};
let host_lower = host.to_lowercase();
if host_lower == "localhost" || host_lower.ends_with(".localhost") {
return true;
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return is_private_ip(ip);
}
let stripped = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = stripped.parse::<std::net::IpAddr>() {
return is_private_ip(ip);
}
if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&(host, 80)) {
let all_addrs: Vec<_> = addrs.collect();
if all_addrs.is_empty() {
return true;
}
for addr in &all_addrs {
if is_private_ip(addr.ip()) {
return true;
}
}
}
false
}
fn is_private_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
}
std::net::IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.octets()[0] == 0xfe && (v6.octets()[1] & 0xc0) == 0x80) || (v6.octets()[0] & 0xfe == 0xfc) || v6.to_ipv4_mapped().is_some_and(|v4| {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
})
}
}
}