bootstrap.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. #!/usr/bin/env python
  2. # License: GPLv3 Copyright: 2022, Kovid Goyal <kovid at kovidgoyal.net>
  3. import base64
  4. import contextlib
  5. import errno
  6. import io
  7. import json
  8. import os
  9. import pwd
  10. import shutil
  11. import subprocess
  12. import sys
  13. import tarfile
  14. import tempfile
  15. import termios
  16. tty_file_obj = None
  17. echo_on = int('ECHO_ON')
  18. data_dir = shell_integration_dir = ''
  19. request_data = int('REQUEST_DATA')
  20. leading_data = b''
  21. login_shell = os.environ.get('SHELL') or '/bin/sh'
  22. try:
  23. login_shell = pwd.getpwuid(os.geteuid()).pw_shell
  24. except KeyError:
  25. pass
  26. export_home_cmd = b'EXPORT_HOME_CMD'
  27. if export_home_cmd:
  28. HOME = base64.standard_b64decode(export_home_cmd).decode('utf-8')
  29. os.chdir(HOME)
  30. else:
  31. HOME = os.path.expanduser('~')
  32. def set_echo(fd, on=False):
  33. if fd < 0:
  34. fd = sys.stdin.fileno()
  35. old = termios.tcgetattr(fd)
  36. new = termios.tcgetattr(fd)
  37. if on:
  38. new[3] |= termios.ECHO
  39. else:
  40. new[3] &= ~termios.ECHO
  41. termios.tcsetattr(fd, termios.TCSANOW, new)
  42. return fd, old
  43. def cleanup():
  44. global tty_file_obj
  45. if tty_file_obj is not None:
  46. if echo_on:
  47. set_echo(tty_file_obj.fileno(), True)
  48. tty_file_obj.close()
  49. tty_file_obj = None
  50. def write_all(fd, data):
  51. if isinstance(data, str):
  52. data = data.encode('utf-8')
  53. data = memoryview(data)
  54. while data:
  55. try:
  56. n = os.write(fd, data)
  57. except BlockingIOError:
  58. continue
  59. if not n:
  60. break
  61. data = data[n:]
  62. def dcs_to_kitty(payload, type='ssh'):
  63. if isinstance(payload, str):
  64. payload = payload.encode('utf-8')
  65. payload = base64.standard_b64encode(payload)
  66. return b'\033P@kitty-' + type.encode('ascii') + b'|' + payload + b'\033\\'
  67. def send_data_request():
  68. write_all(tty_file_obj.fileno(), dcs_to_kitty('id=REQUEST_ID:pwfile=PASSWORD_FILENAME:pw=DATA_PASSWORD'))
  69. def debug(msg):
  70. data = dcs_to_kitty('debug: {}'.format(msg), 'print')
  71. if tty_file_obj is None:
  72. with open(os.ctermid(), 'wb') as fl:
  73. write_all(fl.fileno(), data)
  74. else:
  75. write_all(tty_file_obj.fileno(), data)
  76. def apply_env_vars(raw):
  77. global login_shell
  78. def process_defn(defn):
  79. parts = json.loads(defn)
  80. if len(parts) == 1:
  81. key, val = parts[0], ''
  82. else:
  83. key, val, literal_quote = parts
  84. if not literal_quote:
  85. val = os.path.expandvars(val)
  86. os.environ[key] = val
  87. for line in raw.splitlines():
  88. val = line.split(' ', 1)[-1]
  89. if line.startswith('export '):
  90. process_defn(val)
  91. elif line.startswith('unset '):
  92. os.environ.pop(json.loads(val)[0], None)
  93. login_shell = os.environ.pop('KITTY_LOGIN_SHELL', login_shell)
  94. def move(src, base_dest):
  95. for x in os.listdir(src):
  96. path = os.path.join(src, x)
  97. dest = os.path.join(base_dest, x)
  98. if os.path.islink(path):
  99. try:
  100. os.unlink(dest)
  101. except EnvironmentError:
  102. pass
  103. os.symlink(os.readlink(path), dest)
  104. elif os.path.isdir(path):
  105. if not os.path.exists(dest):
  106. os.makedirs(dest)
  107. move(path, dest)
  108. else:
  109. shutil.move(path, dest)
  110. def compile_terminfo(base):
  111. try:
  112. tic = shutil.which('tic')
  113. except AttributeError:
  114. # python2
  115. for x in os.environ.get('PATH', '').split(os.pathsep):
  116. q = os.path.join(x, 'tic')
  117. if os.access(q, os.X_OK) and os.path.isfile(q):
  118. tic = q
  119. break
  120. else:
  121. tic = ''
  122. if not tic:
  123. return
  124. tname = '.terminfo'
  125. q = os.path.join(base, tname, '78', 'xterm-kitty')
  126. if not os.path.exists(q):
  127. try:
  128. os.makedirs(os.path.dirname(q))
  129. except EnvironmentError as e:
  130. if e.errno != errno.EEXIST:
  131. raise
  132. os.symlink('../x/xterm-kitty', q)
  133. if os.path.exists('/usr/share/misc/terminfo.cdb'):
  134. # NetBSD requires this
  135. os.symlink('../../.terminfo.cdb', os.path.join(base, tname, 'x', 'xterm-kitty'))
  136. tname += '.cdb'
  137. os.environ['TERMINFO'] = os.path.join(HOME, tname)
  138. p = subprocess.Popen(
  139. [tic, '-x', '-o', os.path.join(base, tname), os.path.join(base, '.terminfo', 'kitty.terminfo')],
  140. stdout=subprocess.PIPE, stderr=subprocess.STDOUT
  141. )
  142. output = p.stdout.read()
  143. rc = p.wait()
  144. if rc != 0:
  145. getattr(sys.stderr, 'buffer', sys.stderr).write(output)
  146. raise SystemExit('Failed to compile the terminfo database')
  147. def iter_base64_data(f):
  148. global leading_data
  149. started = 0
  150. while True:
  151. line = f.readline().rstrip()
  152. if started == 0:
  153. if line == b'KITTY_DATA_START':
  154. started = 1
  155. else:
  156. leading_data += line
  157. elif started == 1:
  158. if line == b'OK':
  159. started = 2
  160. else:
  161. raise SystemExit(line.decode('utf-8', 'replace').rstrip())
  162. else:
  163. if line == b'KITTY_DATA_END':
  164. break
  165. yield line
  166. @contextlib.contextmanager
  167. def temporary_directory(dir, prefix):
  168. # tempfile.TemporaryDirectory not available in python2
  169. tdir = tempfile.mkdtemp(dir=dir, prefix=prefix)
  170. try:
  171. yield tdir
  172. finally:
  173. shutil.rmtree(tdir)
  174. def get_data():
  175. global data_dir, shell_integration_dir, leading_data
  176. data = []
  177. data = b''.join(iter_base64_data(tty_file_obj))
  178. if leading_data:
  179. # clear current line as it might have things echoed on it from leading_data
  180. # because we only turn off echo in this script whereas the leading bytes could
  181. # have been sent before the script had a chance to run
  182. sys.stdout.write('\r\033[K')
  183. data = base64.standard_b64decode(data)
  184. with temporary_directory(dir=HOME, prefix='.kitty-ssh-kitten-untar-') as tdir, tarfile.open(fileobj=io.BytesIO(data)) as tf:
  185. try:
  186. tf.extractall(tdir, filter='data')
  187. except TypeError:
  188. tf.extractall(tdir)
  189. with open(tdir + '/data.sh') as f:
  190. env_vars = f.read()
  191. apply_env_vars(env_vars)
  192. data_dir = os.environ.pop('KITTY_SSH_KITTEN_DATA_DIR')
  193. if not os.path.isabs(data_dir):
  194. data_dir = os.path.join(HOME, data_dir)
  195. data_dir = os.path.abspath(data_dir)
  196. shell_integration_dir = os.path.join(data_dir, 'shell-integration')
  197. compile_terminfo(tdir + '/home')
  198. move(tdir + '/home', HOME)
  199. if os.path.exists(tdir + '/root'):
  200. move(tdir + '/root', '/')
  201. def exec_with_better_error(*a):
  202. try:
  203. os.execlp(*a)
  204. except OSError as err:
  205. if err.errno == errno.ENOENT:
  206. raise SystemExit('The program: "' + a[0] + '" was not found')
  207. raise
  208. def exec_zsh_with_integration():
  209. zdotdir = os.environ.get('ZDOTDIR') or ''
  210. if not zdotdir:
  211. zdotdir = HOME
  212. os.environ.pop('KITTY_ORIG_ZDOTDIR', None) # ensure this is not propagated
  213. else:
  214. os.environ['KITTY_ORIG_ZDOTDIR'] = zdotdir
  215. # dont prevent zsh-newuser-install from running
  216. for q in ('.zshrc', '.zshenv', '.zprofile', '.zlogin'):
  217. if os.path.exists(os.path.join(zdotdir, q)):
  218. os.environ['ZDOTDIR'] = shell_integration_dir + '/zsh'
  219. exec_with_better_error(login_shell, os.path.basename(login_shell), '-l')
  220. os.environ.pop('KITTY_ORIG_ZDOTDIR', None) # ensure this is not propagated
  221. def exec_fish_with_integration():
  222. if not os.environ.get('XDG_DATA_DIRS'):
  223. os.environ['XDG_DATA_DIRS'] = shell_integration_dir
  224. else:
  225. os.environ['XDG_DATA_DIRS'] = shell_integration_dir + ':' + os.environ['XDG_DATA_DIRS']
  226. os.environ['KITTY_FISH_XDG_DATA_DIR'] = shell_integration_dir
  227. exec_with_better_error(login_shell, os.path.basename(login_shell), '-l')
  228. def exec_bash_with_integration():
  229. os.environ['ENV'] = os.path.join(shell_integration_dir, 'bash', 'kitty.bash')
  230. os.environ['KITTY_BASH_INJECT'] = '1'
  231. if not os.environ.get('HISTFILE'):
  232. os.environ['HISTFILE'] = os.path.join(HOME, '.bash_history')
  233. os.environ['KITTY_BASH_UNEXPORT_HISTFILE'] = '1'
  234. exec_with_better_error(login_shell, os.path.basename('login_shell'), '--posix')
  235. def exec_with_shell_integration():
  236. shell_name = os.path.basename(login_shell).lower()
  237. if shell_name == 'zsh':
  238. exec_zsh_with_integration()
  239. if shell_name == 'fish':
  240. exec_fish_with_integration()
  241. if shell_name == 'bash':
  242. exec_bash_with_integration()
  243. def install_kitty_bootstrap():
  244. kitty_remote = os.environ.pop('KITTY_REMOTE', '')
  245. kitty_exists = shutil.which('kitty')
  246. if kitty_remote == 'yes' or (kitty_remote == 'if-needed' and not kitty_exists):
  247. kitty_dir = os.path.join(data_dir, 'kitty', 'bin')
  248. if kitty_exists:
  249. os.environ['PATH'] = kitty_dir + os.pathsep + os.environ['PATH']
  250. else:
  251. os.environ['PATH'] = os.environ['PATH'] + os.pathsep + kitty_dir
  252. def main():
  253. global tty_file_obj, login_shell
  254. # the value of O_CLOEXEC below is on macOS which is most likely to not have
  255. # os.O_CLOEXEC being still stuck with python2
  256. tty_file_obj = os.fdopen(os.open(os.ctermid(), os.O_RDWR | getattr(os, 'O_CLOEXEC', 16777216)), 'rb')
  257. try:
  258. if request_data:
  259. set_echo(tty_file_obj.fileno(), on=False)
  260. send_data_request()
  261. get_data()
  262. finally:
  263. cleanup()
  264. cwd = os.environ.pop('KITTY_LOGIN_CWD', '')
  265. install_kitty_bootstrap()
  266. if cwd:
  267. try:
  268. os.chdir(cwd)
  269. except Exception as err:
  270. print(f'Failed to change working directory to: {cwd} with error: {err}', file=sys.stderr)
  271. ksi = frozenset(filter(None, os.environ.get('KITTY_SHELL_INTEGRATION', '').split()))
  272. exec_cmd = b'EXEC_CMD'
  273. if exec_cmd:
  274. os.environ.pop('KITTY_SHELL_INTEGRATION', None)
  275. cmd = base64.standard_b64decode(exec_cmd).decode('utf-8')
  276. exec_with_better_error(login_shell, os.path.basename(login_shell), '-c', cmd)
  277. TEST_SCRIPT # noqa
  278. if ksi and 'no-rc' not in ksi:
  279. exec_with_shell_integration()
  280. os.environ.pop('KITTY_SHELL_INTEGRATION', None)
  281. exec_with_better_error(login_shell, '-' + os.path.basename(login_shell))
  282. main()