use std::path::{Path, PathBuf};
const PROMPT_MAX_BYTES: usize = 8192;
#[derive(Debug, Clone, PartialEq)]
pub enum DeepLink {
NewSession(NewSessionParams),
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct NewSessionParams {
pub cwd: Option<PathBuf>,
pub prompt: Option<String>,
pub profile: Option<String>,
pub model: Option<String>,
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum DeepLinkError {
#[error("malformed URI: {0}")]
Malformed(String),
#[error("unknown scheme action '{0}'; try upgrading zeph")]
UnknownHost(String),
#[error("prompt too long: {0} bytes (limit: 8192)")]
PromptTooLong(usize),
#[error("cwd must be an absolute path")]
CwdNotAbsolute,
#[error("prompt contains disallowed control characters")]
PromptContainsControlChars,
}
pub fn parse_deep_link(uri: &str) -> Result<DeepLink, DeepLinkError> {
let parsed = url::Url::parse(uri).map_err(|e| DeepLinkError::Malformed(e.to_string()))?;
if parsed.scheme() != "zeph" {
return Err(DeepLinkError::Malformed(format!(
"expected scheme 'zeph', got '{}'",
parsed.scheme()
)));
}
let host = parsed
.host_str()
.ok_or_else(|| DeepLinkError::Malformed("missing action host".to_owned()))?;
match host {
"new-session" => {
let params = parse_new_session_params(&parsed)?;
Ok(DeepLink::NewSession(params))
}
other => Err(DeepLinkError::UnknownHost(other.to_owned())),
}
}
fn contains_control_chars(s: &str) -> bool {
s.bytes()
.any(|b| matches!(b, 0x00..=0x08 | 0x0b | 0x0c | 0x0e..=0x1f | 0x7f))
}
fn parse_new_session_params(url: &url::Url) -> Result<NewSessionParams, DeepLinkError> {
let mut params = NewSessionParams::default();
for (key, value) in url.query_pairs() {
match key.as_ref() {
"prompt" => {
let text = value.into_owned();
if text.len() > PROMPT_MAX_BYTES {
return Err(DeepLinkError::PromptTooLong(text.len()));
}
if contains_control_chars(&text) {
return Err(DeepLinkError::PromptContainsControlChars);
}
params.prompt = Some(text);
}
"cwd" => {
let path = PathBuf::from(value.as_ref());
if path.is_relative() {
return Err(DeepLinkError::CwdNotAbsolute);
}
params.cwd = Some(path);
}
"profile" => {
params.profile = Some(value.into_owned());
}
"model" => {
params.model = Some(value.into_owned());
}
"auto" | "-y" => {
tracing::warn!(param = %key, "deep-link: auto-escalation param dropped (INV-NOAUTO)");
}
_ => {
}
}
}
Ok(params)
}
#[derive(Debug, thiserror::Error)]
pub enum CwdValidationError {
#[error("path must be absolute")]
NotAbsolute,
#[error("canonicalization failed: {0}")]
CanonicalizeFailed(std::io::Error),
#[error("path is in a denied location: {0}")]
Denied(String),
#[error("path is outside allowed roots")]
OutsideAllowedRoots,
#[error("path is not a directory")]
NotADirectory,
}
impl From<std::io::Error> for CwdValidationError {
fn from(e: std::io::Error) -> Self {
Self::CanonicalizeFailed(e)
}
}
pub fn validate_deep_link_cwd(
raw: &Path,
allowed_roots: &[PathBuf],
) -> Result<PathBuf, CwdValidationError> {
if !raw.is_absolute() {
return Err(CwdValidationError::NotAbsolute);
}
let canonical = std::fs::canonicalize(raw)?;
let canonical_str = canonical.to_string_lossy().into_owned();
#[cfg(not(target_os = "linux"))]
let folded = canonical_str.to_lowercase();
#[cfg(target_os = "linux")]
let folded = canonical_str.clone();
let denied = build_cwd_denylist();
for entry in &denied {
let is_exact = folded == *entry;
let is_prefix = folded.starts_with(&format!("{entry}/"));
if is_exact || is_prefix {
return Err(CwdValidationError::Denied(canonical_str));
}
}
if !allowed_roots.is_empty() {
let allowed = allowed_roots.iter().any(|root| canonical.starts_with(root));
if !allowed {
return Err(CwdValidationError::OutsideAllowedRoots);
}
}
let meta = canonical.metadata()?;
if !meta.is_dir() {
return Err(CwdValidationError::NotADirectory);
}
Ok(canonical)
}
pub fn build_cwd_denylist() -> Vec<String> {
#[cfg(target_os = "linux")]
let static_entries: &[&str] = &["/proc", "/sys", "/dev", "/etc", "/root", "/boot", "/run"];
#[cfg(target_os = "macos")]
let static_entries: &[&str] = &["/proc", "/sys", "/dev", "/System", "/Library/Keychains"];
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let static_entries: &[&str] = &["/proc", "/sys", "/dev"];
let home = std::env::var("HOME").unwrap_or_default();
let home_entries: Vec<String> = if home.is_empty() {
Vec::new()
} else {
vec![
format!("{home}/.ssh"),
format!("{home}/.gnupg"),
format!("{home}/.aws"),
]
};
let mut result: Vec<String> = static_entries
.iter()
.map(std::string::ToString::to_string)
.collect();
result.extend(home_entries);
#[cfg(target_os = "windows")]
{
use std::env;
if let Ok(sysroot) = env::var("SystemRoot") {
result.push(sysroot);
}
if let Ok(windir) = env::var("WINDIR") {
result.push(windir);
}
}
#[cfg(not(target_os = "linux"))]
{
result = result.into_iter().map(|s| s.to_lowercase()).collect();
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::assert_matches;
#[test]
fn valid_new_session_with_prompt_and_cwd() {
let link = parse_deep_link("zeph://new-session?prompt=Hello&cwd=/home/user").unwrap();
let DeepLink::NewSession(p) = link;
assert_eq!(p.prompt.as_deref(), Some("Hello"));
assert_eq!(p.cwd, Some(PathBuf::from("/home/user")));
}
#[test]
fn invalid_scheme_returns_malformed() {
let err = parse_deep_link("http://example.com").unwrap_err();
assert_matches!(err, DeepLinkError::Malformed(_));
}
#[test]
fn unknown_host_returns_error() {
let err = parse_deep_link("zeph://unknown-action").unwrap_err();
assert_matches!(err, DeepLinkError::UnknownHost(_));
}
#[test]
fn oversized_prompt_returns_error() {
let big = "x".repeat(PROMPT_MAX_BYTES + 1);
let uri = format!("zeph://new-session?prompt={big}");
let err = parse_deep_link(&uri).unwrap_err();
assert_matches!(err, DeepLinkError::PromptTooLong(n) if n == PROMPT_MAX_BYTES + 1);
}
#[test]
fn percent_decoded_prompt() {
let link = parse_deep_link("zeph://new-session?prompt=Hello%20World").unwrap();
let DeepLink::NewSession(p) = link;
assert_eq!(p.prompt.as_deref(), Some("Hello World"));
}
#[test]
fn auto_param_dropped_prompt_still_parsed() {
let link = parse_deep_link("zeph://new-session?auto=true&prompt=ok").unwrap();
let DeepLink::NewSession(p) = link;
assert_eq!(p.prompt.as_deref(), Some("ok"));
}
#[test]
fn relative_cwd_returns_error() {
let err = parse_deep_link("zeph://new-session?cwd=relative/path").unwrap_err();
assert_matches!(err, DeepLinkError::CwdNotAbsolute);
}
#[test]
fn absolute_cwd_stored() {
let link = parse_deep_link("zeph://new-session?cwd=/home/user").unwrap();
let DeepLink::NewSession(p) = link;
assert_eq!(p.cwd, Some(PathBuf::from("/home/user")));
}
#[test]
fn profile_and_model_stored() {
let link = parse_deep_link("zeph://new-session?profile=dev&model=fast").unwrap();
let DeepLink::NewSession(p) = link;
assert_eq!(p.profile.as_deref(), Some("dev"));
assert_eq!(p.model.as_deref(), Some("fast"));
}
#[test]
fn dash_y_param_dropped() {
let link = parse_deep_link("zeph://new-session?-y&prompt=ok").unwrap();
let DeepLink::NewSession(p) = link;
assert_eq!(p.prompt.as_deref(), Some("ok"));
}
#[test]
fn unknown_params_silently_ignored() {
let link = parse_deep_link("zeph://new-session?foo=bar&prompt=hi").unwrap();
let DeepLink::NewSession(p) = link;
assert_eq!(p.prompt.as_deref(), Some("hi"));
}
#[test]
fn nul_byte_in_prompt_returns_control_chars_error() {
let uri = "zeph://new-session?prompt=hello%00world";
let err = parse_deep_link(uri).unwrap_err();
assert!(
matches!(err, DeepLinkError::PromptContainsControlChars),
"expected PromptContainsControlChars, got {err:?}"
);
}
#[test]
fn tab_and_lf_in_prompt_are_accepted() {
let uri = "zeph://new-session?prompt=line1%09tab%0Aline2";
let result = parse_deep_link(uri).unwrap();
let DeepLink::NewSession(p) = result;
assert_eq!(p.prompt.as_deref(), Some("line1\ttab\nline2"));
}
#[test]
fn esc_sequence_in_prompt_returns_control_chars_error() {
let uri = "zeph://new-session?prompt=hello%1bworld";
let err = parse_deep_link(uri).unwrap_err();
assert!(
matches!(err, DeepLinkError::PromptContainsControlChars),
"expected PromptContainsControlChars for ESC, got {err:?}"
);
}
#[test]
fn prompt_exactly_8192_bytes_ok() {
let prompt = "a".repeat(8192);
let uri = format!("zeph://new-session?prompt={prompt}");
let result = parse_deep_link(&uri);
assert!(result.is_ok(), "exactly 8192 bytes should be accepted");
}
#[test]
fn bare_new_session_no_params() {
let result = parse_deep_link("zeph://new-session").unwrap();
let DeepLink::NewSession(p) = result;
assert!(p.cwd.is_none());
assert!(p.prompt.is_none());
assert!(p.profile.is_none());
assert!(p.model.is_none());
}
proptest::proptest! {
#[test]
fn fuzz_parse_deep_link_no_panic(s in ".*") {
let _ = parse_deep_link(&s);
}
}
}