Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
use url::Url;
const DEFAULT_TITLE: &str = "Warp";
const BASE_APP_PATH: &str = "/app";
pub fn update_browser_url(url: Option<Url>, force_redirect: bool) {
let mut new_url = url;
if new_url.is_none() {
new_url = get_base_app_url()
}
if let Some(unwrapped_url) = new_url {
let window = gloo::utils::window();
if force_redirect {
let _ = window.location().set_href(unwrapped_url.as_str());
} else if let Ok(history) = window.history() {
history
.replace_state_with_url(
&wasm_bindgen::JsValue::null(),
DEFAULT_TITLE,
Some(unwrapped_url.as_str()),
)
.unwrap_or_else(|_| {
log::error!("Failed to replace browser state");
crate::platform::wasm::emit_event(
crate::platform::wasm::WarpEvent::ErrorLogged {
error: String::from("Failed to replace browser state"),
},
);
});
} else {
log::error!("Failed to get gloo history while trying to update browser url");
}
} else {
log::error!("Failed to get new url to update browser with");
}
}
pub fn parse_current_url() -> Option<Url> {
let loc = gloo::utils::document().location();
let unwrapped_loc = loc.as_ref()?;
let the_href = unwrapped_loc.href();
if the_href.is_err() {
return None;
}
if let Ok(parsed_url) = Url::parse(the_href.expect("Invalid href parsed from url").as_str()) {
return Some(parsed_url);
}
None
}
fn get_base_app_url() -> Option<Url> {
if let Some(current_url) = parse_current_url() {
let mut new_url = current_url.clone();
new_url.set_path(BASE_APP_PATH);
new_url.set_query(None);
return Some(new_url);
}
log::error!("Failed to get the base url");
None
}
+121
View File
@@ -0,0 +1,121 @@
use std::{collections::HashMap, fmt::Display};
use crate::{
send_telemetry_from_app_ctx, server::telemetry::TelemetryEvent, terminal::shell::ShellType,
};
use regex::Regex;
use url::Url;
use warp_util::path::{is_posix_portable_pathname, ShellFamily};
use warpui::AppContext;
use crate::root_view::SubshellCommandArg;
use anyhow::{anyhow, Result};
/// String of hex digits meant to represent a Docker container ID.
#[derive(Debug)]
struct DockerContainerId(String);
impl TryFrom<String> for DockerContainerId {
type Error = anyhow::Error;
fn try_from(input: String) -> Result<Self, Self::Error> {
// Note: We could do a further check to validate that this Docker container ID actually exists and/or is running.
if input.is_empty() || input.len() > 64 {
Err(anyhow!(
"Docker container IDs must be between 1 and 64 bytes long"
))
} else if input.chars().any(|c| !c.is_ascii_hexdigit()) {
Err(anyhow!(
"Could not find valid docker container id to open warpified shell"
))
} else {
Ok(DockerContainerId(input))
}
}
}
impl Display for DockerContainerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Given a Url with query parameters in the correct format, dispatch an action to create a new tab
/// (or open a new window if there is no window), then run a command to open a subshell into the
/// specified Docker container, and then warpify that new subshell.
pub fn open_docker_container(url: &Url, ctx: &mut AppContext) -> Result<()> {
let query_params: HashMap<String, String> = url
.query_pairs()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let container_id = query_params
.get("container_id")
.and_then(|container_id| DockerContainerId::try_from(container_id.to_owned()).ok())
.ok_or(anyhow!("no valid container ID parameter found"))?;
let shell_path = query_params
.get("shell")
// TODO(CORE-2658): Make this filter less restrictive without reducing security.
.filter(|shell_path| is_posix_portable_pathname(shell_path))
// TODO(CORE-2658): Our Docker extension lets users specify any shell, but we're only accepting
// shells we can bootstrap. We should probably change the Docker extension to only surface
// shells we can bootstrap.
.filter(|shell_path| ShellType::from_name(shell_path).is_some())
.ok_or(anyhow!("no valid shell parameter found"))?;
// This NAME_REGEX specifies this format of linux user names. It's a very
// common pattern, but some systems might have a different configuration.
let username_pattern =
Regex::new(r"^[a-z][-a-z0-9_]*\$?$").expect("NAME_REGEX should be valid.");
let user = match query_params.get("user") {
Some(user) if username_pattern.is_match(user.as_str()) => Some(user),
Some(_) => anyhow::bail!("Invalid user parameter found."),
None => None,
};
// Command example: docker exec -it --user 'admin' 'container_id' 'zsh'.
// TODO(CORE-2658): This [`ShellFamily::shell_escape`] function is built with `bash` in mind but we need to
// properly escape for all our officially supported shells.
// Assume MacOS/Linux and therefore POSIX shell. Running Docker on Windows requires WSL anyway.
let mut docker_exec_command = String::from("docker exec -it");
if let Some(user) = user {
docker_exec_command
.push_str(format!(" --user '{}' ", ShellFamily::Posix.shell_escape(user)).as_str());
}
// We don't need to escape the container_id because we already checked that it had no special
// characters.
docker_exec_command.push_str(
format!(
" '{}' '{}'",
container_id,
ShellFamily::Posix.shell_escape(shell_path)
)
.as_str(),
);
let shell_type = ShellType::from_name(shell_path);
// Opens a new window if there is none.
ctx.dispatch_global_action(
"root_view:open_new_tab_insert_subshell_command_and_bootstrap_if_supported",
&SubshellCommandArg {
command: docker_exec_command,
shell_type,
},
);
send_telemetry_from_app_ctx!(
TelemetryEvent::OpenAndWarpifyDockerSubshell { shell_type },
ctx
);
Ok(())
}
#[cfg(test)]
#[path = "docker_test.rs"]
mod tests;
+58
View File
@@ -0,0 +1,58 @@
use warpui::App;
use crate::{
auth::{auth_manager::AuthManager, AuthStateProvider},
server::{
server_api::ServerApiProvider, telemetry::context_provider::AppTelemetryContextProvider,
},
};
use super::*;
#[test]
// Tests behavior based on which query parameters are required.
fn test_open_docker_container() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
app.add_singleton_model(AuthManager::new_for_test);
let base_url = Url::parse("warplocal://action/docker/open_subshell")
.expect("base url should be successfully parsed");
let container_id = (
"container_id",
"85aa47c9ef3fbd338cb3cfe45c99eaed0c57f374c3c47bf3da3e44fd6b5c3399",
);
let shell_path = ("shell", "/bin/bash");
let user = ("user", "root");
let mut container_and_shell = base_url.to_owned();
container_and_shell
.query_pairs_mut()
.append_pair(container_id.0, container_id.1)
.append_pair(shell_path.0, shell_path.1);
let mut container_shell_user = base_url.to_owned();
container_shell_user
.query_pairs_mut()
.append_pair(container_id.0, container_id.1)
.append_pair(shell_path.0, shell_path.1)
.append_pair(user.0, user.1);
let mut missing_container_id = base_url.to_owned();
missing_container_id
.query_pairs_mut()
.append_pair(shell_path.0, shell_path.1);
let mut missing_shell = base_url.to_owned();
missing_shell
.query_pairs_mut()
.append_pair(container_id.0, container_id.1);
app.update(|app_ctx| {
assert!(open_docker_container(&container_shell_user, app_ctx).is_ok());
assert!(open_docker_container(&container_and_shell, app_ctx).is_ok());
assert!(open_docker_container(&missing_shell, app_ctx).is_err());
assert!(open_docker_container(&missing_container_id, app_ctx).is_err());
});
});
}
+1350
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
use crate::cloud_object::extract_server_id_and_object_type_from_warp_drive_link;
use crate::drive::OpenWarpDriveObjectArgs;
use crate::ChannelState;
use url::Url;
#[derive(PartialEq, Debug)]
pub enum WarpWebLink {
Session,
DriveObject(Box<OpenWarpDriveObjectArgs>),
}
pub fn get_item_data_from_warp_link(url: &Url) -> Option<WarpWebLink> {
if url.origin() == ChannelState::server_root_domain() {
url.path_segments().and_then(|mut path_segments| {
path_segments.next().and_then(|segment| match segment {
"drive" => extract_server_id_and_object_type_from_warp_drive_link(url)
.map(|args| WarpWebLink::DriveObject(Box::new(args))),
"session" => Some(WarpWebLink::Session),
_ => None,
})
})
} else {
None
}
}
+535
View File
@@ -0,0 +1,535 @@
use self::parse_url_paths::{get_item_data_from_warp_link, WarpWebLink};
use super::*;
use crate::launch_configs::launch_config::make_mock_single_window_launch_config;
use crate::linear::{LinearAction, LinearIssueWork};
use crate::ChannelState;
#[test]
fn test_find_matching_config() {
let mut configs: Vec<LaunchConfig> = vec![];
for i in 0..5 {
add_mock_config_with_name(
(String::from("config") + i.to_string().as_str()).as_str(),
&mut configs,
);
}
let with_extension = "config1.yaml";
assert_eq!(
find_matching_config(with_extension, &configs),
Some(&configs[1])
);
let no_extension = "config4";
assert_eq!(
find_matching_config(no_extension, &configs),
Some(&configs[4])
);
let caps_insensitive = "ConFig3";
assert_eq!(
find_matching_config(caps_insensitive, &configs),
Some(&configs[3])
);
let missing_config = "missing";
assert_eq!(find_matching_config(missing_config, &configs), None);
}
#[test]
fn test_find_matching_config_with_spaces() {
let mut configs: Vec<LaunchConfig> = vec![];
for i in 0..3 {
add_mock_config_with_name(
(String::from("config") + i.to_string().as_str()).as_str(),
&mut configs,
);
}
let with_space = "config 3.yaml";
add_mock_config_with_name(with_space, &mut configs);
assert_eq!(
find_matching_config(with_space, &configs),
Some(&configs[3])
);
let more_space = " a ";
add_mock_config_with_name(more_space, &mut configs);
assert_eq!(
find_matching_config(more_space, &configs),
Some(&configs[4])
);
}
#[test]
fn test_find_matching_configs_special_chars() {
let mut configs: Vec<LaunchConfig> = vec![];
for i in 0..3 {
add_mock_config_with_name(
(String::from("config") + i.to_string().as_str()).as_str(),
&mut configs,
);
}
// test special characters
let special_ascii = "yes! this_works,too-even[braces}and(parens'.";
add_mock_config_with_name(special_ascii, &mut configs);
assert_eq!(
find_matching_config(special_ascii, &configs),
Some(&configs[3])
);
// test emojis
let bread = "🍞";
add_mock_config_with_name(bread, &mut configs);
assert_eq!(find_matching_config(bread, &configs), Some(&configs[4]));
}
fn add_mock_config_with_name(name: &str, configs: &mut Vec<LaunchConfig>) {
let mut new_config = make_mock_single_window_launch_config();
new_config.name = name.to_string();
new_config.windows[0].tabs[0].title = Some(String::from("First tab from config ") + name);
configs.push(new_config);
}
#[test]
fn test_get_launch_config_path() {
assert_eq!(
get_launch_config_path("/path/to/a/config"),
Some(String::from("path/to/a/config")),
);
assert_eq!(
get_launch_config_path("/hello%20world.yaml"),
Some(String::from("hello world.yaml")),
);
assert_eq!(
get_launch_config_path("/%3Bhello%20%23world!"),
Some(String::from(";hello #world!")),
);
assert_eq!(
get_launch_config_path("/yes%21%20this_works%2Ctoo-even%5Bbraces%7Dand%28parens%27."),
Some(String::from("yes! this_works,too-even[braces}and(parens'."))
);
assert_eq!(
get_launch_config_path("/%F0%9F%8D%9E"),
Some(String::from("🍞"))
);
assert_eq!(
get_launch_config_path("/..filename_.with_dots.."),
Some(String::from("..filename_.with_dots.."))
);
}
#[test]
fn test_get_launch_config_path_invalid() {
assert_eq!(get_launch_config_path(""), None);
assert_eq!(get_launch_config_path("/"), None);
assert_eq!(get_launch_config_path("%2F"), None);
assert_eq!(get_launch_config_path("/../outside"), None);
assert_eq!(get_launch_config_path("/..%2Foutside"), None);
assert_eq!(get_launch_config_path("/A/.."), None);
assert_eq!(get_launch_config_path("/A/../B"), None);
assert_eq!(get_launch_config_path("//absolute"), None);
assert_eq!(get_launch_config_path("/%2Fabsolute sneaky"), None);
assert_eq!(get_launch_config_path("//../very_bad/.."), None);
}
#[test]
fn test_remove_extension() {
assert_eq!(remove_extension(""), None);
assert_eq!(remove_extension(".yaml"), Some(""));
assert_eq!(remove_extension(" .yaml"), Some(" "));
assert_eq!(remove_extension("config.yaml"), Some("config"));
assert_eq!(remove_extension("..yaml"), Some("."));
assert_eq!(remove_extension("config"), None);
assert_eq!(remove_extension("🍞.yaml"), Some("🍞"));
}
#[test]
fn test_warp_web_link_notebook() {
assert_eq!(
get_item_data_from_warp_link(
&Url::parse(&format!(
"{}/drive/notebook/Performance-Analysis-LkDlnAe34vfYD2JXsAkssc?focused_folder_id=test_uid00000000000123&invitee_email=test@example.com",
ChannelState::server_root_url()
))
.unwrap()
),
Some(WarpWebLink::DriveObject(Box::new(OpenWarpDriveObjectArgs {
object_type: ObjectType::Notebook,
server_id: ServerId::from_string_lossy("LkDlnAe34vfYD2JXsAkssc"),
settings: OpenWarpDriveObjectSettings {
focused_folder_id: Some(ServerId::from(123)),
invitee_email: Some(String::from("test@example.com")),
},
})))
);
}
#[test]
fn test_warp_web_link_session() {
assert_eq!(
get_item_data_from_warp_link(
&Url::parse(&format!(
"{}/session/317d0686-7a0b-4b67-806b-aaa3e9df501b?
pwd=6f727249-af9f-4025-a240-59df40a4c64b",
ChannelState::server_root_url()
))
.unwrap()
),
Some(WarpWebLink::Session)
);
}
#[test]
fn test_warp_web_link_workflow() {
assert_eq!(
get_item_data_from_warp_link(
&Url::parse(&format!(
"{}/drive/workflow/Remove-all-stopped-docker-container-image-and-volumes-ZCJSkai2gpwTqpBFs5HOfZ",
ChannelState::server_root_url()
))
.unwrap()
),
Some(WarpWebLink::DriveObject(Box::new(OpenWarpDriveObjectArgs {
object_type: ObjectType::Workflow,
server_id: ServerId::from_string_lossy("ZCJSkai2gpwTqpBFs5HOfZ"),
settings: OpenWarpDriveObjectSettings::default(),
})))
);
}
#[test]
fn test_warp_web_link_failure() {
assert_eq!(
get_item_data_from_warp_link(&Url::parse("https://google.com").unwrap()),
None
);
}
#[test]
fn test_action_create_environment_parse() {
let url = Url::parse(&format!(
"{}://action/create_environment?repo=foo&repo=bar",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
match action {
Action::CreateEnvironment { repos } => {
assert_eq!(repos, vec!["foo".to_owned(), "bar".to_owned()]);
}
_ => panic!("unexpected action: {action:?}"),
}
}
#[test]
fn test_action_focus_cloud_mode_parse() {
let url = Url::parse(&format!(
"{}://action/focus_cloud_mode",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
assert!(matches!(action, Action::FocusCloudMode));
}
#[test]
fn test_action_create_environment_parse_no_repos() {
let url = Url::parse(&format!(
"{}://action/create_environment",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
match action {
Action::CreateEnvironment { repos } => {
assert!(repos.is_empty());
}
_ => panic!("unexpected action: {action:?}"),
}
}
#[test]
fn test_action_cloud_agent_setup_parse() {
let url = Url::parse(&format!(
"{}://action/cloud_agent_setup",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
assert!(matches!(action, Action::CloudAgentSetup));
}
#[test]
fn test_action_new_cloud_agent_conversation_parse() {
let url = Url::parse(&format!(
"{}://action/new_cloud_agent_conversation",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
assert!(matches!(action, Action::NewCloudAgentConversation));
}
#[test]
fn test_action_new_agent_conversation_parse() {
let url = Url::parse(&format!(
"{}://action/new_agent_conversation",
ChannelState::url_scheme()
))
.unwrap();
let action = Action::parse(&url).unwrap();
assert!(matches!(action, Action::NewAgentConversation));
}
#[test]
fn test_validate_custom_uri_linear() {
let url = Url::parse(&format!(
"{}://linear/work?prompt=hello",
ChannelState::url_scheme()
))
.unwrap();
let host = validate_custom_uri(&url).unwrap();
assert!(matches!(host, UriHost::Linear));
}
#[test]
fn test_linear_action_parse_work() {
let url = Url::parse(&format!(
"{}://linear/work?prompt=hello",
ChannelState::url_scheme()
))
.unwrap();
let action = LinearAction::parse(&url).unwrap();
assert_eq!(action, LinearAction::WorkOnIssue);
}
#[test]
fn test_linear_action_parse_unknown_path() {
let url = Url::parse(&format!("{}://linear/unknown", ChannelState::url_scheme())).unwrap();
assert!(LinearAction::parse(&url).is_err());
}
#[test]
fn test_linear_issue_work_with_prompt() {
let url = Url::parse(&format!(
"{}://linear/work?prompt=fix+the+bug",
ChannelState::url_scheme()
))
.unwrap();
let args = LinearIssueWork::from_url(&url);
assert_eq!(args.prompt.as_deref(), Some("fix the bug"));
}
#[test]
fn test_linear_issue_work_without_prompt() {
let url = Url::parse(&format!("{}://linear/work", ChannelState::url_scheme())).unwrap();
let args = LinearIssueWork::from_url(&url);
assert!(args.prompt.is_none());
}
#[test]
fn test_linear_issue_work_empty_prompt() {
let url = Url::parse(&format!(
"{}://linear/work?prompt=",
ChannelState::url_scheme()
))
.unwrap();
let args = LinearIssueWork::from_url(&url);
assert!(args.prompt.is_none());
}
// -- handle_incoming_uri redaction -------------------------------------------
//
// These tests cover the fix for GH #737: the entry log inside
// `handle_incoming_uri` used to write the full URL (including the Firebase
// `refresh_token` query parameter) to `warp.log` at `info` level before any
// redaction ran. They validate the redaction helper and the error messages
// produced by `validate_custom_uri` to ensure that the fallback `warn`
// emitted on invalid URIs never embeds the query string either.
/// The redacted log representation must contain scheme/host/path for triage
/// but must never contain the query string or any token material.
#[test]
fn safe_url_log_fields_redacts_refresh_token() {
let url = Url::parse(&format!(
"{}://auth/desktop_redirect?refresh_token=SENSITIVE_TOKEN&state=abc&user_uid=u",
ChannelState::url_scheme()
))
.unwrap();
let logged = safe_url_log_fields(&url);
assert!(
logged.contains(&format!("scheme={}", ChannelState::url_scheme())),
"expected scheme in redacted log, got: {logged}"
);
assert!(
logged.contains("host=auth"),
"expected host in redacted log, got: {logged}"
);
assert!(
logged.contains("path=/desktop_redirect"),
"expected path in redacted log, got: {logged}"
);
assert!(
!logged.contains("refresh_token"),
"redacted log must not contain refresh_token: {logged}"
);
assert!(
!logged.contains("SENSITIVE_TOKEN"),
"redacted log must not contain the token value: {logged}"
);
assert!(
!logged.contains("state="),
"redacted log must not contain state query param: {logged}"
);
assert!(
!logged.contains("user_uid"),
"redacted log must not contain user_uid: {logged}"
);
}
/// The redacted log representation must drop generic OAuth query parameters
/// (`code=`, `access_token=`, `custom_token=`, `token=`) regardless of host.
#[test]
fn safe_url_log_fields_redacts_generic_oauth_params() {
let url = Url::parse(&format!(
"{}://mcp/oauth_callback?code=AUTH_CODE&state=xyz&access_token=AT&custom_token=CT&token=RAW",
ChannelState::url_scheme()
))
.unwrap();
let logged = safe_url_log_fields(&url);
for forbidden in [
"code=",
"AUTH_CODE",
"access_token",
"AT",
"custom_token",
"CT",
"token=RAW",
"state=",
] {
assert!(
!logged.contains(forbidden),
"redacted log must not contain {forbidden:?}: {logged}"
);
}
assert!(logged.contains("host=mcp"), "expected host: {logged}");
assert!(
logged.contains("path=/oauth_callback"),
"expected path: {logged}"
);
}
/// Drive links carry user-identifiable `invitee_email` values in the query.
/// The entry log must not surface them on non-dogfood channels.
#[test]
fn safe_url_log_fields_redacts_invitee_email() {
let url = Url::parse(&format!(
"{}://drive/notebook?id=abc&invitee_email=alice@example.com",
ChannelState::url_scheme()
))
.unwrap();
let logged = safe_url_log_fields(&url);
assert!(
!logged.contains("alice@example.com"),
"redacted log must not contain invitee email: {logged}"
);
assert!(
!logged.contains("invitee_email"),
"redacted log must not contain invitee_email key: {logged}"
);
assert!(logged.contains("host=drive"), "expected host: {logged}");
}
/// URL fragments are not currently used as secret carriers by Warp today, but
/// the entry log's contract is "scheme + host + path only", so fragments must
/// be dropped as well.
#[test]
fn safe_url_log_fields_drops_fragment() {
let url = Url::parse(&format!(
"{}://auth/desktop_redirect#sensitive_fragment",
ChannelState::url_scheme()
))
.unwrap();
let logged = safe_url_log_fields(&url);
assert!(
!logged.contains("sensitive_fragment"),
"redacted log must not contain url fragment: {logged}"
);
assert!(
!logged.contains('#'),
"redacted log must not contain any fragment separator: {logged}"
);
}
/// `file://` URLs route through the same entry log. `file://` URLs on macOS
/// have no host; the helper must not panic and must report `host=-` so the
/// format string stays well-formed.
#[test]
fn safe_url_log_fields_handles_file_urls_without_host() {
let url = Url::parse("file:///tmp/foo.md").unwrap();
let logged = safe_url_log_fields(&url);
assert!(logged.contains("scheme=file"), "expected scheme: {logged}");
assert!(
logged.contains("host=-"),
"expected host placeholder: {logged}"
);
assert!(
logged.contains("path=/tmp/foo.md"),
"expected path: {logged}"
);
}
/// `validate_custom_uri` returns `anyhow::Error`s whose messages feed the
/// non-dogfood `log::warn!("Custom URI is invalid: {e:?}")` fallback in
/// `handle_incoming_uri`. Those messages must never embed the full URL, its
/// query string, or its fragment — otherwise the fallback warn line becomes
/// a second secret leak.
#[test]
fn validate_custom_uri_errors_do_not_leak_query_string() {
// Unexpected scheme.
let url = Url::parse("https://auth/desktop_redirect?refresh_token=LEAKED").unwrap();
let err = validate_custom_uri(&url).unwrap_err();
let msg = format!("{err:?}");
assert!(!msg.contains("refresh_token"), "{msg}");
assert!(!msg.contains("LEAKED"), "{msg}");
// Unexpected host.
let url = Url::parse(&format!(
"{}://unknown_host/desktop_redirect?refresh_token=LEAKED",
ChannelState::url_scheme()
))
.unwrap();
let err = validate_custom_uri(&url).unwrap_err();
let msg = format!("{err:?}");
assert!(!msg.contains("refresh_token"), "{msg}");
assert!(!msg.contains("LEAKED"), "{msg}");
// Unexpected path for a host that doesn't allow arbitrary paths.
let url = Url::parse(&format!(
"{}://auth/not_the_redirect?refresh_token=LEAKED",
ChannelState::url_scheme()
))
.unwrap();
let err = validate_custom_uri(&url).unwrap_err();
let msg = format!("{err:?}");
assert!(!msg.contains("refresh_token"), "{msg}");
assert!(!msg.contains("LEAKED"), "{msg}");
}
+241
View File
@@ -0,0 +1,241 @@
#[cfg(target_family = "wasm")]
use crate::uri::browser_url_handler::parse_current_url;
use crate::ChannelState;
use anyhow::{anyhow, Result};
use url::Url;
use uuid::Uuid;
#[cfg(target_family = "wasm")]
use warp_core::context_flag::ContextFlag;
#[derive(Debug)]
/// Represents an intent parsed from a web url
pub enum WebIntent {
SessionView(Url),
ConversationView(Url),
DriveObject(Url),
SettingsView(Url),
Home(Url),
Action(Url),
}
impl WebIntent {
pub fn try_from_url(url: &Url) -> Result<Self> {
// Only handle URLs that point at the current channel's web server.
let server_root = ChannelState::server_root_url();
let server_root_url = Url::parse(&server_root)?;
if url.scheme() != server_root_url.scheme()
|| url.domain() != server_root_url.domain()
|| url.port_or_known_default() != server_root_url.port_or_known_default()
{
return Err(anyhow!("Attempting to parse invalid url: {}", url));
}
let segments = url
.path_segments()
.map(|segments| segments.collect::<Vec<_>>());
if let Some(segments) = segments {
let url_scheme = ChannelState::url_scheme();
if segments.is_empty() {
return Ok(WebIntent::Home(Url::parse(&format!(
"{url_scheme}://home"
))?));
} else {
match segments[0] {
"app" => {
return Ok(WebIntent::Home(Url::parse(&format!(
"{url_scheme}://home"
))?));
}
// For sessions, we expect the URL to be in the format: {scheme}/session/{session_id}
"session" => {
if segments.len() != 2 {
return Err(anyhow!("Attempting to parse invalid url: {}", url));
}
let session_id = segments[1];
// Validate that the session ID is a UUID. If it's not, this isn't a
// valid shared-session URL and we should return an error so the
// caller can ignore it.
if Uuid::parse_str(session_id).is_err() {
return Err(anyhow!("Attempting to parse invalid url: {}", url));
}
let mut session_intent = Url::parse(
format!("{url_scheme}://shared_session/{session_id}").as_str(),
)
.map_err(|_| anyhow!("Attempting to parse invalid url: {}", url))?;
// Preserve any query parameters (e.g. pwd, preview) from the original URL.
if let Some(query) = url.query() {
session_intent.set_query(Some(query));
}
return Ok(WebIntent::SessionView(session_intent));
}
// For conversations, we expect the URL to be in the format: {scheme}/conversation/{conversation_id}
"conversation" => {
if segments.len() != 2 {
return Err(anyhow!("Attempting to parse invalid url: {}", url));
}
let conversation_id = segments[1];
let conversation_intent = Url::parse(
format!("{url_scheme}://conversation/{conversation_id}").as_str(),
)
.map_err(|_| anyhow!("Attempting to parse invalid url: {}", url))?;
return Ok(WebIntent::ConversationView(conversation_intent));
}
// For drive objects, we expect the URL to be of the format: {scheme}/drive/{object-type}/{object-name}-{object-id}?focused_folder_id={focused_folder_id}
// The focused_folder_id is optional, and if it is not provided, we will not include it in the intent url.
"drive" => {
if segments.len() != 3 {
return Err(anyhow!("Attempting to parse invalid url: {}", url));
}
let id_and_name: Vec<&str> =
segments[segments.len() - 1].split('-').collect();
let id = id_and_name[id_and_name.len() - 1];
let object_type = segments[segments.len() - 2];
if let Ok(mut drive_intent) =
Url::parse(format!("{url_scheme}://drive/{object_type}").as_str())
{
drive_intent.set_query(url.query());
drive_intent.query_pairs_mut().append_pair("id", id);
return Ok(WebIntent::DriveObject(drive_intent));
}
}
"settings" => {
// For the settings links, we expect the URL to be of the format: {scheme}/settings/{sub_section}?{query_str}
if segments.len() != 2 {
return Err(anyhow!("Attempting to parse invalid url: {}", url));
}
let sub_section = segments[segments.len() - 1];
let query_str = url.query().unwrap_or_default();
if query_str.is_empty() {
return Err(anyhow!("Attempting to parse invalid url: {}", url));
}
if let Ok(settings_intent) = Url::parse(
format!("{url_scheme}://settings/{sub_section}?{query_str}").as_str(),
) {
return Ok(WebIntent::SettingsView(settings_intent));
}
}
"action" => {
if segments.len() != 2 {
return Err(anyhow!("Attempting to parse invalid url: {}", url));
}
let action_type = segments[1];
// Allowlist of valid actions,
// since we shouldn't expose all Warp actions as web URLs.
const ALLOWED_ACTIONS: &[&str] = &["open-repo", "focus_cloud_mode"];
if !ALLOWED_ACTIONS.contains(&action_type) {
return Err(anyhow!("Unknown action type in url: {}", action_type));
}
if let Ok(action_intent) =
Url::parse(format!("{url_scheme}://action/{action_type}").as_str())
{
return Ok(WebIntent::Action(action_intent));
}
}
_ => return Err(anyhow!("Attempting to parse invalid url: {}", url)),
}
}
}
Err(anyhow!("Attempting to parse invalid url: {}", url))
}
/// Convert this web intent into the underlying native desktop URL.
pub fn into_intent_url(self) -> Url {
match self {
WebIntent::SessionView(url) => url,
WebIntent::ConversationView(url) => url,
WebIntent::DriveObject(url) => url,
WebIntent::SettingsView(url) => url,
WebIntent::Home(url) => url,
WebIntent::Action(url) => url,
}
}
}
/// Attempts to rewrite a Warp web URL into a native desktop intent URL (warp://...).
/// Returns `None` if the URL is not a recognized Warp web intent.
pub fn maybe_rewrite_web_url_to_intent(url: &Url) -> Option<Url> {
WebIntent::try_from_url(url)
.ok()
.map(WebIntent::into_intent_url)
}
/// On WASM warp, fires an event to try and open the given link on the desktop app.
#[cfg(target_family = "wasm")]
pub fn open_url_on_desktop(url: &Url) {
match WebIntent::try_from_url(url) {
Ok(WebIntent::ConversationView(intent))
| Ok(WebIntent::DriveObject(intent))
| Ok(WebIntent::SessionView(intent))
| Ok(WebIntent::Action(intent)) => {
crate::platform::wasm::emit_event(crate::platform::wasm::WarpEvent::OpenOnNative {
url: intent.into(),
});
}
_ => {
log::warn!("Attempting to open invalid url on desktop app:{url}");
}
};
}
#[cfg(target_family = "wasm")]
fn set_context_flags_from_url(url: Url) {
match WebIntent::try_from_url(&url) {
Ok(WebIntent::SessionView(_)) => ContextFlag::set_shared_session_only(),
Ok(WebIntent::ConversationView(_)) => ContextFlag::set_conversation_only(),
Ok(WebIntent::DriveObject(_)) => ContextFlag::set_warp_drive_link_only(),
Ok(WebIntent::SettingsView(_)) => ContextFlag::set_settings_link_only(),
Ok(WebIntent::Home(_)) => ContextFlag::set_warp_home_link_only(),
Ok(WebIntent::Action(_)) => {} // No special context flag for actions
_ => {}
}
// Allow directly setting flags through query params in dogfood.
if ChannelState::channel().is_dogfood() {
for (param, value) in url.query_pairs() {
let Ok(flag) = param.parse::<ContextFlag>() else {
continue;
};
let Ok(bool_value) = value.parse::<bool>() else {
continue;
};
flag.set(bool_value);
}
}
}
/// Looks at the current URL and converts it into an app intent.
#[cfg(target_family = "wasm")]
pub fn current_web_intent() -> Option<WebIntent> {
let Some(current_url) = parse_current_url() else {
log::warn!("Unable to parse the current url");
return None;
};
WebIntent::try_from_url(&current_url).ok()
}
// Looks at the current url and converts it into an app intent.
// NOTE: This is only intended for use with target_family = "wasm"
#[cfg(target_family = "wasm")]
pub fn parse_web_intent_from_current_url() -> Option<Url> {
current_web_intent().map(WebIntent::into_intent_url)
}
#[cfg(target_family = "wasm")]
pub fn set_context_flags_from_current_url() {
let Some(current_url) = parse_current_url() else {
log::warn!("Unable to parse the current url");
return;
};
set_context_flags_from_url(current_url);
}