use crate::core::state::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenTarget {
Current,
Tab,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollDir {
Up,
Down,
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum YankWhat {
Url,
Title,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HintTarget {
#[default]
Current,
Tab,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
Open { target: OpenTarget, input: String },
Back(u32),
Forward(u32),
Reload { bypass_cache: bool },
Stop,
Scroll(ScrollDir, u32),
ScrollPage { down: bool, half: bool },
ScrollToPercent(u8),
Hint(HintTarget),
TabClose,
TabNext(u32),
TabPrev(u32),
TabSelect(usize),
Undo,
TabClone,
TabMove(i32),
TabOnly,
ModeEnter(Mode),
ModeLeave,
SetCommandLine(String),
Accept,
Yank(YankWhat),
QuickmarkSave(String),
QuickmarkLoad(String),
QuickmarkDel(String),
BookmarkAdd,
BookmarkLoad(String),
BookmarkDel(String),
Set { key: String, value: String },
ConfigSource,
DarkMode,
SessionSave(String),
SessionLoad(String),
PluginReload,
Memory,
FindNext,
FindPrev,
ZoomIn,
ZoomOut,
ZoomReset,
ZoomSet(u32),
Permissions,
Quit,
Nop,
}
impl Command {
pub fn parse(input: &str) -> Result<Command, String> {
if let Some(text) = input.strip_prefix("cmd-set-text ") {
return Ok(Command::SetCommandLine(text.to_string()));
}
if input.trim() == "cmd-set-text" {
return Ok(Command::SetCommandLine(String::new()));
}
let mut parts = input.split_whitespace();
let Some(name) = parts.next() else {
return Err("empty command".to_string());
};
let rest: Vec<&str> = parts.collect();
let arg = rest.join(" ");
let count = |default: u32| rest.first().and_then(|s| s.parse::<u32>().ok()).unwrap_or(default);
let cmd = match name {
"open" | "o" => Command::Open {
target: OpenTarget::Current,
input: arg,
},
"tabopen" | "t" => Command::Open {
target: OpenTarget::Tab,
input: arg,
},
"back" => Command::Back(count(1)),
"forward" => Command::Forward(count(1)),
"reload" | "r" => Command::Reload {
bypass_cache: rest.contains(&"--force"),
},
"stop" => Command::Stop,
"scroll" => Command::Scroll(parse_dir(rest.first())?, 1),
"scroll-page" => Command::ScrollPage {
down: matches!(rest.first(), Some(&"down")),
half: rest.contains(&"half"),
},
"scroll-to-perc" => Command::ScrollToPercent(count(100).min(100) as u8),
"hint" => Command::Hint(HintTarget::Current),
"hint-tab" => Command::Hint(HintTarget::Tab),
"tab-close" | "d" => Command::TabClose,
"tab-next" => Command::TabNext(count(1)),
"tab-prev" => Command::TabPrev(count(1)),
"tab-clone" => Command::TabClone,
"tab-only" => Command::TabOnly,
"tab-move" => Command::TabMove(
rest.first()
.and_then(|s| s.parse::<i32>().ok())
.ok_or_else(|| format!("tab-move needs an offset: {arg}"))?,
),
"undo" => Command::Undo,
"tab-focus" | "tab-select" => {
let n = rest
.first()
.and_then(|s| s.parse::<usize>().ok())
.ok_or_else(|| format!("tab-focus needs an index: {arg}"))?;
Command::TabSelect(n)
}
"mode-enter" => Command::ModeEnter(parse_mode(rest.first())?),
"mode-leave" => Command::ModeLeave,
"yank" => Command::Yank(match rest.first() {
None | Some(&"url") => YankWhat::Url,
Some(&"title") => YankWhat::Title,
Some(other) => return Err(format!("unknown yank target: {other}")),
}),
"quickmark-save" => Command::QuickmarkSave(arg),
"quickmark-load" => Command::QuickmarkLoad(arg),
"quickmark-del" => Command::QuickmarkDel(arg),
"bookmark-add" => Command::BookmarkAdd,
"bookmark-load" => Command::BookmarkLoad(arg),
"bookmark-del" => Command::BookmarkDel(arg),
"set" => {
let key = rest
.first()
.ok_or_else(|| "set needs a key".to_string())?
.to_string();
let value = rest[1..].join(" ");
Command::Set { key, value }
}
"config-source" => Command::ConfigSource,
"darkmode" => Command::DarkMode,
"session-save" => Command::SessionSave(arg),
"session-load" => Command::SessionLoad(arg),
"plugin-reload" => Command::PluginReload,
"memory" => Command::Memory,
"find-next" | "search-next" => Command::FindNext,
"find-prev" | "search-prev" => Command::FindPrev,
"zoom-in" => Command::ZoomIn,
"zoom-out" => Command::ZoomOut,
"zoom-reset" => Command::ZoomReset,
"zoom" => Command::ZoomSet(
rest.first()
.and_then(|s| s.trim_end_matches('%').parse::<u32>().ok())
.ok_or_else(|| format!("zoom needs a percentage: {arg}"))?,
),
"permissions" => Command::Permissions,
"quit" | "q" | "qa" => Command::Quit,
"nop" => Command::Nop,
other => return Err(format!("unknown command: {other}")),
};
Ok(cmd)
}
pub fn with_count(self, count: u32) -> Command {
match self {
Command::Scroll(dir, _) => Command::Scroll(dir, count),
Command::Back(_) => Command::Back(count),
Command::Forward(_) => Command::Forward(count),
Command::TabNext(_) => Command::TabNext(count),
Command::TabPrev(_) => Command::TabPrev(count),
other => other,
}
}
}
pub fn is_safe_external_target(input: &str) -> bool {
let t = input.trim();
if t.eq_ignore_ascii_case("about:blank") {
return true;
}
match scheme_of(t) {
Some(scheme) => matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https"),
None => true,
}
}
pub fn is_remote_safe(cmd: &Command) -> bool {
match cmd {
Command::Open { input, .. } => is_safe_external_target(input),
Command::Back(_)
| Command::Forward(_)
| Command::Reload { .. }
| Command::Stop
| Command::Scroll(..)
| Command::ScrollPage { .. }
| Command::ScrollToPercent(_)
| Command::TabClose
| Command::TabNext(_)
| Command::TabPrev(_)
| Command::TabSelect(_)
| Command::TabClone
| Command::TabMove(_)
| Command::TabOnly
| Command::Undo => true,
_ => false,
}
}
fn scheme_of(input: &str) -> Option<String> {
let colon = input.find(':')?;
let scheme = &input[..colon];
if scheme.is_empty() || !scheme.starts_with(|c: char| c.is_ascii_alphabetic()) {
return None;
}
if !scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
{
return None;
}
let after = &input[colon + 1..];
if after.starts_with("//") || !scheme.contains('.') {
Some(scheme.to_string())
} else {
None
}
}
fn parse_dir(arg: Option<&&str>) -> Result<ScrollDir, String> {
match arg {
Some(&"up") => Ok(ScrollDir::Up),
Some(&"down") => Ok(ScrollDir::Down),
Some(&"left") => Ok(ScrollDir::Left),
Some(&"right") => Ok(ScrollDir::Right),
other => Err(format!("invalid scroll direction: {other:?}")),
}
}
fn parse_mode(arg: Option<&&str>) -> Result<Mode, String> {
match arg {
Some(&"normal") => Ok(Mode::Normal),
Some(&"insert") => Ok(Mode::Insert),
Some(&"command") => Ok(Mode::Command),
other => Err(format!("unknown mode: {other:?}")),
}
}
pub const COMMAND_CATALOG: &[(&str, &str)] = &[
("open", "Open a URL or search in the current tab"),
("tabopen", "Open a URL or search in a new tab"),
("back", "Go back in history"),
("forward", "Go forward in history"),
("reload", "Reload the page"),
("stop", "Stop loading"),
("scroll", "Scroll in a direction"),
("scroll-page", "Scroll by a page"),
("scroll-to-perc", "Scroll to a percentage of the page"),
("hint", "Follow a link by keyboard"),
("hint-tab", "Open a hinted link in a new tab"),
("tab-close", "Close the current tab"),
("tab-next", "Focus the next tab"),
("tab-prev", "Focus the previous tab"),
("tab-focus", "Focus a tab by index"),
("tab-clone", "Duplicate the current tab"),
("tab-move", "Move the current tab"),
("tab-only", "Close all other tabs"),
("undo", "Reopen the last closed tab"),
("mode-enter", "Enter an input mode"),
("mode-leave", "Return to normal mode"),
("yank", "Copy the page URL or title"),
("quickmark-save", "Save the page as a named quickmark"),
("quickmark-load", "Open a quickmark by name"),
("quickmark-del", "Delete a quickmark"),
("bookmark-add", "Bookmark the current page"),
("bookmark-load", "Open a bookmark"),
("bookmark-del", "Delete a bookmark"),
("set", "Set a configuration value"),
("config-source", "Reload the configuration file"),
("darkmode", "Toggle web-content dark mode"),
("session-save", "Save the current tabs as a session"),
("session-load", "Restore a saved session"),
("plugin-reload", "Recompile and reload plugins"),
("memory", "Report memory use and view count"),
("find-next", "Jump to the next search match"),
("find-prev", "Jump to the previous search match (best-effort)"),
("zoom-in", "Increase page zoom"),
("zoom-out", "Decrease page zoom"),
("zoom-reset", "Reset page zoom to the default"),
("zoom", "Set page zoom to a percentage"),
("permissions", "Manage per-site permissions"),
("quit", "Quit the browser"),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_external_targets_allow_web_and_bare_hosts() {
assert!(is_safe_external_target("https://example.com"));
assert!(is_safe_external_target("http://example.com/p"));
assert!(is_safe_external_target("example.com"));
assert!(is_safe_external_target("example.com:8080/path"));
assert!(is_safe_external_target("search terms here"));
assert!(is_safe_external_target("about:blank"));
}
#[test]
fn safe_external_targets_reject_dangerous_schemes() {
assert!(!is_safe_external_target("file:///etc/passwd"));
assert!(!is_safe_external_target("file:///home/me/.ssh/id_rsa"));
assert!(!is_safe_external_target("data:text/html,<script>1</script>"));
assert!(!is_safe_external_target("javascript:alert(1)"));
assert!(!is_safe_external_target("about:config"));
}
#[test]
fn remote_safe_allows_navigation_not_sensitive_commands() {
assert!(is_remote_safe(&Command::Open {
target: OpenTarget::Tab,
input: "https://a.test".to_string(),
}));
assert!(is_remote_safe(&Command::TabNext(1)));
assert!(!is_remote_safe(&Command::Open {
target: OpenTarget::Tab,
input: "file:///etc/passwd".to_string(),
}));
assert!(!is_remote_safe(&Command::Quit));
assert!(!is_remote_safe(&Command::PluginReload));
assert!(!is_remote_safe(&Command::Set {
key: "x".to_string(),
value: "y".to_string(),
}));
assert!(!is_remote_safe(&Command::SessionSave("s".to_string())));
}
}