mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-02-01 11:34:59 +01:00
Workaround for badly designed program that try to open links in untrusted documents that point to executables on the local system. They should really not be doing that, but, since such programs do exist, let's be safe by default. Users that use better designed programs and want to disable the confirmations can do so by writing their own launch-actions.conf
304 lines
9.1 KiB
Python
304 lines
9.1 KiB
Python
#!/usr/bin/env python
|
|
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
|
|
|
|
|
|
import os
|
|
import posixpath
|
|
import shlex
|
|
from collections.abc import Iterable, Iterator
|
|
from contextlib import suppress
|
|
from typing import Any, NamedTuple, cast
|
|
from urllib.parse import ParseResult, unquote, urlparse
|
|
|
|
from .conf.utils import KeyAction, to_cmdline_implementation
|
|
from .constants import config_dir
|
|
from .fast_data_types import get_options
|
|
from .guess_mime_type import guess_type
|
|
from .options.utils import ActionAlias, MapType, resolve_aliases_and_parse_actions
|
|
from .types import run_once
|
|
from .typing import MatchType
|
|
from .utils import expandvars, get_editor, log_error, resolved_shell
|
|
|
|
|
|
class MatchCriteria(NamedTuple):
|
|
type: MatchType
|
|
value: str
|
|
|
|
|
|
class OpenAction(NamedTuple):
|
|
match_criteria: tuple[MatchCriteria, ...]
|
|
actions: tuple[KeyAction, ...]
|
|
|
|
|
|
def parse(lines: Iterable[str]) -> Iterator[OpenAction]:
|
|
match_criteria: list[MatchCriteria] = []
|
|
raw_actions: list[str] = []
|
|
alias_map: dict[str, list[ActionAlias]] = {}
|
|
entries = []
|
|
|
|
for line in lines:
|
|
line = line.strip()
|
|
if line.startswith('#'):
|
|
continue
|
|
if not line:
|
|
if match_criteria and raw_actions:
|
|
entries.append((tuple(match_criteria), tuple(raw_actions)))
|
|
match_criteria = []
|
|
raw_actions = []
|
|
continue
|
|
parts = line.split(maxsplit=1)
|
|
if len(parts) != 2:
|
|
continue
|
|
key, rest = parts
|
|
key = key.lower()
|
|
if key == 'action':
|
|
raw_actions.append(rest)
|
|
elif key in ('mime', 'ext', 'protocol', 'file', 'path', 'url', 'fragment_matches'):
|
|
if key != 'url':
|
|
rest = rest.lower()
|
|
match_criteria.append(MatchCriteria(cast(MatchType, key), rest))
|
|
elif key == 'action_alias':
|
|
try:
|
|
alias_name, alias_val = rest.split(maxsplit=1)
|
|
except Exception:
|
|
continue
|
|
alias_map[alias_name] = [ActionAlias(alias_name, alias_val)]
|
|
else:
|
|
log_error(f'Ignoring malformed open actions line: {line}')
|
|
|
|
if match_criteria and raw_actions:
|
|
entries.append((tuple(match_criteria), tuple(raw_actions)))
|
|
|
|
with to_cmdline_implementation.filter_env_vars(
|
|
'URL', 'FILE_PATH', 'FILE', 'FRAGMENT', 'URL_PATH', 'NETLOC',
|
|
EDITOR=shlex.join(get_editor()),
|
|
SHELL=resolved_shell(get_options())[0]
|
|
):
|
|
for (mc, action_defns) in entries:
|
|
actions: list[KeyAction] = []
|
|
for defn in action_defns:
|
|
actions.extend(resolve_aliases_and_parse_actions(defn, alias_map, MapType.OPEN_ACTION))
|
|
yield OpenAction(mc, tuple(actions))
|
|
|
|
|
|
def url_matches_criterion(purl: 'ParseResult', url: str, unquoted_path: str, mc: MatchCriteria) -> bool:
|
|
if mc.type == 'url':
|
|
import re
|
|
try:
|
|
pat = re.compile(mc.value)
|
|
except re.error:
|
|
return False
|
|
return pat.search(unquote(url)) is not None
|
|
|
|
if mc.type == 'mime':
|
|
import fnmatch
|
|
mt = guess_type(unquoted_path, allow_filesystem_access=purl.scheme in ('', 'file'))
|
|
if not mt:
|
|
return False
|
|
mt = mt.lower()
|
|
for mpat in mc.value.split(','):
|
|
mpat = mpat.strip()
|
|
with suppress(Exception):
|
|
if fnmatch.fnmatchcase(mt, mpat):
|
|
return True
|
|
return False
|
|
|
|
if mc.type == 'ext':
|
|
if not purl.path:
|
|
return False
|
|
path = unquoted_path.lower()
|
|
for ext in mc.value.split(','):
|
|
ext = ext.strip()
|
|
if path.endswith(f'.{ext}'):
|
|
return True
|
|
return False
|
|
|
|
if mc.type == 'protocol':
|
|
protocol = (purl.scheme or 'file').lower()
|
|
for key in mc.value.split(','):
|
|
if key.strip() == protocol:
|
|
return True
|
|
return False
|
|
|
|
if mc.type == 'fragment_matches':
|
|
import re
|
|
try:
|
|
pat = re.compile(mc.value)
|
|
except re.error:
|
|
return False
|
|
|
|
return pat.search(unquote(purl.fragment)) is not None
|
|
|
|
if mc.type == 'path':
|
|
import fnmatch
|
|
try:
|
|
return fnmatch.fnmatchcase(unquoted_path.lower(), mc.value)
|
|
except Exception:
|
|
return False
|
|
|
|
if mc.type == 'file':
|
|
import fnmatch
|
|
try:
|
|
fname = posixpath.basename(unquoted_path)
|
|
except Exception:
|
|
return False
|
|
try:
|
|
return fnmatch.fnmatchcase(fname.lower(), mc.value)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def url_matches_criteria(purl: 'ParseResult', url: str, unquoted_path: str, criteria: Iterable[MatchCriteria]) -> bool:
|
|
for x in criteria:
|
|
try:
|
|
if not url_matches_criterion(purl, url, unquoted_path, x):
|
|
return False
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
|
|
def actions_for_url_from_list(url: str, actions: Iterable[OpenAction]) -> Iterator[KeyAction]:
|
|
try:
|
|
purl = urlparse(url)
|
|
except Exception:
|
|
return
|
|
path = unquote(purl.path)
|
|
up = purl.path
|
|
netloc = unquote(purl.netloc) if purl.netloc else ''
|
|
if purl.query:
|
|
up += f'?{purl.query}'
|
|
frag = unquote(purl.fragment) if purl.fragment else ''
|
|
if frag:
|
|
up += f'#{purl.fragment}'
|
|
|
|
env = {
|
|
'URL': url,
|
|
'FILE_PATH': path,
|
|
'URL_PATH': up,
|
|
'FILE': posixpath.basename(path),
|
|
'FRAGMENT': frag,
|
|
'NETLOC': netloc,
|
|
}
|
|
|
|
def expand(x: Any) -> Any:
|
|
as_bytes = isinstance(x, bytes)
|
|
if as_bytes:
|
|
x = x.decode('utf-8')
|
|
if isinstance(x, str):
|
|
ans = expandvars(x, env, fallback_to_os_env=False)
|
|
if as_bytes:
|
|
return ans.encode('utf-8')
|
|
return ans
|
|
return x
|
|
|
|
for action in actions:
|
|
if url_matches_criteria(purl, url, path, action.match_criteria):
|
|
for ac in action.actions:
|
|
yield ac._replace(args=tuple(map(expand, ac.args)))
|
|
return
|
|
|
|
|
|
actions_cache: dict[str, tuple[os.stat_result, tuple[OpenAction, ...]]] = {}
|
|
|
|
|
|
def load_actions_from_path(path: str) -> tuple[OpenAction, ...]:
|
|
try:
|
|
st = os.stat(path)
|
|
except OSError:
|
|
return ()
|
|
x = actions_cache.get(path)
|
|
if x is None or x[0].st_mtime != st.st_mtime:
|
|
with open(path) as f:
|
|
actions_cache[path] = st, tuple(parse(f))
|
|
else:
|
|
return x[1]
|
|
return actions_cache[path][1]
|
|
|
|
|
|
def load_open_actions() -> tuple[OpenAction, ...]:
|
|
return load_actions_from_path(os.path.join(config_dir, 'open-actions.conf'))
|
|
|
|
|
|
def load_launch_actions() -> tuple[OpenAction, ...]:
|
|
return load_actions_from_path(os.path.join(config_dir, 'launch-actions.conf'))
|
|
|
|
|
|
def clear_caches() -> None:
|
|
actions_cache.clear()
|
|
|
|
|
|
@run_once
|
|
def default_open_actions() -> tuple[OpenAction, ...]:
|
|
return tuple(parse('''\
|
|
# Open kitty HTML docs links
|
|
protocol kitty+doc
|
|
action show_kitty_doc $URL_PATH
|
|
'''.splitlines()))
|
|
|
|
|
|
@run_once
|
|
def default_launch_actions() -> tuple[OpenAction, ...]:
|
|
return tuple(parse('''\
|
|
# Open script files. Change confirm-always to confirm-never or confirm-if-needed to
|
|
# disable confirmation for all or executable files respectively.
|
|
protocol file
|
|
ext sh,command,tool
|
|
action launch --hold --type=os-window kitten __shebang__ confirm-always $FILE_PATH $SHELL
|
|
|
|
# Open shell specific script files
|
|
protocol file
|
|
ext fish,bash,zsh
|
|
action launch --hold --type=os-window kitten __shebang__ confirm-always $FILE_PATH __ext__
|
|
|
|
# Open directories
|
|
protocol file
|
|
mime inode/directory
|
|
action launch --type=os-window --cwd -- $FILE_PATH
|
|
|
|
# Open executable file. Remove kitten __confirm_and_run_exe__ to execute
|
|
# without confirmation.
|
|
protocol file
|
|
mime inode/executable,application/vnd.microsoft.portable-executable
|
|
action launch --hold --type=os-window -- kitten __confirm_and_run_exe__ $FILE_PATH
|
|
|
|
# Open text files without fragments in the editor
|
|
protocol file
|
|
mime text/*
|
|
action launch --type=os-window -- $EDITOR -- $FILE_PATH
|
|
|
|
# Open image files with icat
|
|
protocol file
|
|
mime image/*
|
|
action launch --type=os-window kitten icat --hold -- $FILE_PATH
|
|
|
|
# Open ssh URLs with ssh command
|
|
protocol ssh
|
|
action launch --type=os-window ssh -- $URL
|
|
'''.splitlines()))
|
|
|
|
|
|
def actions_for_url(url: str, actions_spec: str | None = None) -> Iterator[KeyAction]:
|
|
if actions_spec is None:
|
|
actions = load_open_actions()
|
|
else:
|
|
actions = tuple(parse(actions_spec.splitlines()))
|
|
found = False
|
|
for action in actions_for_url_from_list(url, actions):
|
|
found = True
|
|
yield action
|
|
if not found:
|
|
yield from actions_for_url_from_list(url, default_open_actions())
|
|
|
|
|
|
def actions_for_launch(url: str) -> Iterator[KeyAction]:
|
|
# Custom launch actions using kitty URL scheme needs to be prefixed with `kitty:///launch/`
|
|
if url.startswith('kitty://') and not url.startswith('kitty:///launch/'):
|
|
return
|
|
found = False
|
|
for action in actions_for_url_from_list(url, load_launch_actions()):
|
|
found = True
|
|
yield action
|
|
if not found:
|
|
yield from actions_for_url_from_list(url, default_launch_actions())
|