use super::{IOS_GROUP, run_bridge_tool as run};
use crate::tools::context::ToolContext;
use crate::tools::ios_bridge::{IosToolBridge, OPEN_URL_TIMEOUT};
use crate::tools::{AllowedCaller, Tool, ToolExecError};
use choreo_keystore::ServiceCredential;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct OpenUrlArgs {
pub(crate) url: String,
}
impl JsonSchema for OpenUrlArgs {
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("OpenUrlArgs")
}
fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "object",
"properties": {
"url": {
"type": "string",
"pattern": "^(https|mailto):",
"description": "The URL to open. Only https and mailto schemes are allowed."
}
},
"required": ["url"],
"additionalProperties": false
})
}
}
impl OpenUrlArgs {
fn validate(&self) -> Result<(), ToolExecError> {
if self.url.chars().any(char::is_control) {
return Err(ToolExecError(
"open_url: the URL must not contain control characters".into(),
));
}
let parsed = url::Url::parse(&self.url).map_err(|e| {
ToolExecError(format!(
"open_url only supports https and mailto URLs (not a valid URL: {e})"
))
})?;
match parsed.scheme() {
"https" | "mailto" => {}
other => {
return Err(ToolExecError(format!(
"open_url only supports https and mailto URLs (got scheme: {other:?})"
)));
}
}
Ok(())
}
}
pub(crate) struct OpenUrl {
bridge: Arc<dyn IosToolBridge>,
timeout: Duration,
}
impl OpenUrl {
pub(crate) fn new(bridge: Arc<dyn IosToolBridge>) -> Self {
Self {
bridge,
timeout: OPEN_URL_TIMEOUT,
}
}
#[cfg(test)]
pub(crate) fn with_timeout(bridge: Arc<dyn IosToolBridge>, timeout: Duration) -> Self {
Self { bridge, timeout }
}
}
impl Tool for OpenUrl {
type Args = OpenUrlArgs;
type Return = String;
type Error = ToolExecError;
fn name(&self) -> &'static str {
"open_url"
}
fn group(&self) -> &'static str {
IOS_GROUP
}
fn description(&self) -> &'static str {
"Open an https or mailto URL on the device (hands off to the system)."
}
fn describe_invocation(&self, args: &Self::Args) -> String {
format!("Opening URL: {}.", args.url)
}
fn allowed_callers(&self) -> Vec<AllowedCaller> {
vec![AllowedCaller::Direct]
}
fn execute(
&self,
args: Self::Args,
_x_credentials: Option<&ServiceCredential>,
_working_dir: Option<&Path>,
ctx: Option<&ToolContext>,
) -> Result<Self::Return, Self::Error> {
args.validate()?;
let value = run(&self.bridge, self.name(), &args, self.timeout, ctx)?;
let opened = value
.get("opened")
.and_then(|v| v.as_bool())
.unwrap_or(true);
Ok(if opened {
"URL opened.".to_string()
} else {
"The system declined to open the URL.".to_string()
})
}
fn return_string(ret: &Self::Return) -> String {
ret.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ios::test_util::test_ctx;
use crate::tools::ios_bridge::{MockBridge, MockResponse, ToolBridgeError};
use std::sync::atomic::Ordering;
fn tool(m: &Arc<MockBridge>, timeout: Duration) -> OpenUrl {
OpenUrl::with_timeout(Arc::clone(m) as Arc<dyn IosToolBridge>, timeout)
}
fn ctx() -> ToolContext {
test_ctx()
}
#[test]
fn schema_narrows_to_https_mailto() {
let schema = Tool::schema(&OpenUrl::new(Arc::new(MockBridge::default())));
assert_eq!(schema["properties"]["url"]["pattern"], "^(https|mailto):");
}
#[test]
fn https_and_mailto_pass_validation() {
let m = Arc::new(MockBridge::default());
for url in ["https://example.com/x?y=1", "mailto:a@b.c"] {
m.script(MockResponse::Reply(Ok(serde_json::Value::Null)));
tool(&m, OPEN_URL_TIMEOUT)
.execute(OpenUrlArgs { url: url.into() }, None, None, None)
.unwrap_or_else(|e| panic!("valid url {url} rejected: {e}"));
}
let dispatched = m.dispatched();
assert_eq!(dispatched.len(), 2);
assert_eq!(dispatched[0].name, "open_url");
assert_eq!(dispatched[1].args_json, r#"{"url":"mailto:a@b.c"}"#);
}
#[test]
fn disallowed_schemes_rejected_before_dispatch() {
let m = Arc::new(MockBridge::default());
for url in [
"file:///etc/passwd",
"http://insecure.example",
"ftp://x",
"data:text/html,hi",
"myapp://deep/link",
"nonsense",
] {
let err = tool(&m, OPEN_URL_TIMEOUT)
.execute(OpenUrlArgs { url: url.into() }, None, None, None)
.unwrap_err();
assert!(
err.to_string().contains("https and mailto"),
"url {url} failed with the wrong error: {err}"
);
}
assert!(
m.dispatched().is_empty(),
"rejected URLs must never dispatch"
);
}
#[test]
fn control_characters_rejected_before_dispatch() {
let m = Arc::new(MockBridge::default());
for url in ["https://ok\u{0}bad", "https://x\ny", "mailto:a@b\u{7}"] {
let err = tool(&m, OPEN_URL_TIMEOUT)
.execute(OpenUrlArgs { url: url.into() }, None, None, None)
.unwrap_err();
assert!(err.to_string().contains("control characters"));
}
assert!(m.dispatched().is_empty());
}
#[test]
fn bridge_errors_map_sensibly() {
let m = Arc::new(MockBridge::default());
m.script(MockResponse::Reply(Err(ToolBridgeError::Platform(
"springboard refused".into(),
))));
let err = tool(&m, OPEN_URL_TIMEOUT)
.execute(
OpenUrlArgs {
url: "https://x".into(),
},
None,
None,
None,
)
.unwrap_err();
assert_eq!(err.to_string(), "springboard refused");
let m = Arc::new(MockBridge::default());
let err = tool(&m, OPEN_URL_TIMEOUT)
.execute(
OpenUrlArgs {
url: "https://x".into(),
},
None,
None,
None,
)
.unwrap_err();
assert!(err.to_string().contains("bridge unavailable"));
}
#[test]
fn zero_timeout_times_out() {
let m = Arc::new(MockBridge::default());
m.script(MockResponse::Reply(Ok(serde_json::Value::Null)));
let err = tool(&m, Duration::ZERO)
.execute(
OpenUrlArgs {
url: "https://x".into(),
},
None,
None,
None,
)
.unwrap_err();
assert!(err.to_string().contains("timed out"));
}
#[test]
fn cancel_at_entry_never_dispatches_and_cancel_wins_on_reply() {
let m = Arc::new(MockBridge::default());
let context = ctx();
context.cancelled.store(true, Ordering::Relaxed);
let err = tool(&m, OPEN_URL_TIMEOUT)
.execute(
OpenUrlArgs {
url: "https://x".into(),
},
None,
None,
Some(&context),
)
.unwrap_err();
assert_eq!(err.to_string(), "request was canceled");
assert!(m.dispatched().is_empty());
let (bridge, _inner, context) =
crate::tools::ios::test_util::cancel_race_fixture(Ok(serde_json::Value::Null));
let err = OpenUrl::with_timeout(
Arc::clone(&bridge) as Arc<dyn IosToolBridge>,
OPEN_URL_TIMEOUT,
)
.execute(
OpenUrlArgs {
url: "https://y".into(),
},
None,
None,
Some(&context),
)
.unwrap_err();
assert_eq!(err.to_string(), "request was canceled");
assert_eq!(bridge.dispatched().len(), 1, "dispatch must have happened");
assert_eq!(
bridge.cancels().len(),
1,
"late-cancel path must call pending.cancel()"
);
}
#[test]
fn declined_open_is_surfaced() {
let m = Arc::new(MockBridge::default());
m.script(MockResponse::Reply(Ok(
serde_json::json!({"opened": false}),
)));
let ret = tool(&m, OPEN_URL_TIMEOUT)
.execute(
OpenUrlArgs {
url: "https://x".into(),
},
None,
None,
None,
)
.unwrap();
assert!(ret.contains("declined"), "got: {ret}");
}
#[test]
fn direct_only_callers() {
let callers = OpenUrl::new(Arc::new(MockBridge::default())).allowed_callers();
assert_eq!(callers, vec![AllowedCaller::Direct]);
}
}