fileutil.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. #!/usr/bin/env python3
  2. #
  3. # MMGen Wallet, a terminal-based cryptocurrency wallet
  4. # Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. fileutil: Routines that read, write, execute or stat files
  20. """
  21. # 09 Mar 2024: make all utils accept Path instances as arguments
  22. import sys, os
  23. from .color import set_vt100
  24. from .util import (
  25. msg,
  26. die,
  27. get_extension,
  28. is_utf8,
  29. capfirst,
  30. make_full_path,
  31. strip_comments,
  32. )
  33. def check_or_create_dir(path):
  34. try:
  35. os.listdir(path)
  36. except:
  37. if os.getenv('MMGEN_TEST_SUITE'):
  38. if os.path.exists(path): # path is a link or regular file
  39. from subprocess import run
  40. run(['rm', '-rf', str(path)])
  41. set_vt100()
  42. try:
  43. os.makedirs(path, 0o700)
  44. except:
  45. die(2, f'ERROR: unable to read or create path ‘{path}’')
  46. def check_binary(args):
  47. from subprocess import run, DEVNULL
  48. try:
  49. run(args, stdout=DEVNULL, stderr=DEVNULL, check=True)
  50. except:
  51. die(2, f'{args[0]!r} binary missing, not in path, or not executable')
  52. set_vt100()
  53. def shred_file(fn, verbose=False):
  54. check_binary(['shred', '--version'])
  55. from subprocess import run
  56. run(
  57. ['shred', '--force', '--iterations=30', '--zero', '--remove=wipesync']
  58. + (['--verbose'] if verbose else [])
  59. + [str(fn)],
  60. check=True)
  61. set_vt100()
  62. def _check_file_type_and_access(fname, ftype, blkdev_ok=False):
  63. import stat
  64. access, op_desc = (
  65. (os.W_OK, 'writ') if ftype in ('output file', 'output directory') else
  66. (os.R_OK, 'read'))
  67. if ftype == 'output directory':
  68. ok_types = [(stat.S_ISDIR, 'output directory')]
  69. else:
  70. ok_types = [
  71. (stat.S_ISREG, 'regular file'),
  72. (stat.S_ISLNK, 'symbolic link')
  73. ]
  74. if blkdev_ok:
  75. if not sys.platform in ('win32',):
  76. ok_types.append((stat.S_ISBLK, 'block device'))
  77. try:
  78. mode = os.stat(fname).st_mode
  79. except:
  80. die('FileNotFound', f'Requested {ftype} ‘{fname}’ not found')
  81. for t in ok_types:
  82. if t[0](mode):
  83. break
  84. else:
  85. ok_list = ' or '.join(t[1] for t in ok_types)
  86. die(1, f'Requested {ftype} ‘{fname}’ is not a {ok_list}')
  87. if not os.access(fname, access):
  88. die(1, f'Requested {ftype} ‘{fname}’ is not {op_desc}able by you')
  89. return True
  90. def check_infile(f, blkdev_ok=False):
  91. return _check_file_type_and_access(f, 'input file', blkdev_ok=blkdev_ok)
  92. def check_outfile(f, blkdev_ok=False):
  93. return _check_file_type_and_access(f, 'output file', blkdev_ok=blkdev_ok)
  94. def check_outdir(f):
  95. return _check_file_type_and_access(f, 'output directory')
  96. def get_seed_file(cfg, nargs, wallets=None, invoked_as=None):
  97. wallets = wallets or cfg._args
  98. from .filename import find_file_in_dir
  99. from .wallet.mmgen import wallet
  100. wf = find_file_in_dir(wallet, cfg.data_dir)
  101. wd_from_opt = bool(cfg.hidden_incog_input_params or cfg.in_fmt) # have wallet data from opt?
  102. if len(wallets) + (wd_from_opt or bool(wf)) < nargs:
  103. if not wf:
  104. msg('No default wallet found, and no other seed source was specified')
  105. cfg._usage()
  106. elif len(wallets) > nargs:
  107. cfg._usage()
  108. elif len(wallets) == nargs and wf and invoked_as != 'gen':
  109. cfg._util.qmsg('Warning: overriding default wallet with user-supplied wallet')
  110. if wallets or wf:
  111. check_infile(wallets[0] if wallets else wf)
  112. return str(wallets[0]) if wallets else (wf, None)[wd_from_opt] # could be a Path instance
  113. def _open_or_die(filename, mode, silent=False):
  114. try:
  115. return open(filename, mode)
  116. except:
  117. if silent:
  118. die(2, '')
  119. else:
  120. fn = {0:'STDIN', 1:'STDOUT', 2:'STDERR'}[filename] if isinstance(filename, int) else f'‘{filename}’'
  121. desc = 'reading' if 'r' in mode else 'writing'
  122. die(2, f'Unable to open file {fn} for {desc}')
  123. def write_data_to_file(
  124. cfg,
  125. outfile,
  126. data,
  127. desc = 'data',
  128. ask_write = False,
  129. ask_write_prompt = '',
  130. ask_write_default_yes = True,
  131. ask_overwrite = True,
  132. ask_tty = True,
  133. no_tty = False,
  134. quiet = False,
  135. binary = False,
  136. ignore_opt_outdir = False,
  137. outdir = None,
  138. check_data = False,
  139. cmp_data = None):
  140. outfile = str(outfile) # could be a Path instance
  141. if quiet:
  142. ask_tty = ask_overwrite = False
  143. if cfg.quiet:
  144. ask_overwrite = False
  145. if ask_write_default_yes is False or ask_write_prompt:
  146. ask_write = True
  147. def do_stdout():
  148. cfg._util.qmsg('Output to STDOUT requested')
  149. if cfg.stdin_tty:
  150. if no_tty:
  151. die(2, f'Printing {desc} to screen is not allowed')
  152. if (ask_tty and not cfg.quiet) or binary:
  153. from .ui import confirm_or_raise
  154. confirm_or_raise(
  155. cfg,
  156. message = '',
  157. action = f'output {desc} to screen')
  158. else:
  159. try:
  160. of = os.readlink(f'/proc/{os.getpid()}/fd/1') # Linux
  161. except:
  162. of = None # Windows
  163. if of:
  164. if of[:5] == 'pipe:':
  165. if no_tty:
  166. die(2, f'Writing {desc} to pipe is not allowed')
  167. if ask_tty and not cfg.quiet:
  168. from .ui import confirm_or_raise
  169. confirm_or_raise(
  170. cfg,
  171. message = '',
  172. action = f'output {desc} to pipe')
  173. msg('')
  174. of2, pd = os.path.relpath(of), os.path.pardir
  175. msg('Redirecting output to file {!r}'.format(of if of2[:len(pd)] == pd else of2))
  176. else:
  177. msg('Redirecting output to file')
  178. if binary and sys.platform == 'win32':
  179. import msvcrt
  180. msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
  181. # MSWin workaround. See msg_r()
  182. try:
  183. sys.stdout.write(data.decode() if isinstance(data, bytes) else data)
  184. except:
  185. os.write(1, data if isinstance(data, bytes) else data.encode())
  186. def do_file(outfile, ask_write_prompt):
  187. if (outdir or (cfg.outdir and not ignore_opt_outdir)) and not os.path.isabs(outfile):
  188. outfile = make_full_path(str(outdir or cfg.outdir), outfile) # outdir could be Path instance
  189. if ask_write:
  190. if not ask_write_prompt:
  191. ask_write_prompt = f'Save {desc}?'
  192. from .ui import keypress_confirm
  193. if not keypress_confirm(
  194. cfg,
  195. ask_write_prompt,
  196. default_yes = ask_write_default_yes):
  197. die(1, f'{capfirst(desc)} not saved')
  198. hush = False
  199. if os.path.lexists(outfile) and ask_overwrite:
  200. from .ui import confirm_or_raise
  201. confirm_or_raise(
  202. cfg,
  203. message = '',
  204. action = f'File {outfile!r} already exists\nOverwrite?')
  205. msg(f'Overwriting file {outfile!r}')
  206. hush = True
  207. # not atomic, but better than nothing
  208. # if cmp_data is empty, file can be either empty or non-existent
  209. if check_data:
  210. d = ''
  211. try:
  212. with open(outfile, ('r', 'rb')[bool(binary)]) as fp:
  213. d = fp.read()
  214. except:
  215. pass
  216. if d != cmp_data:
  217. die(3, f'{desc} in file {outfile!r} has been altered by some other program! Aborting file write')
  218. # To maintain portability, always open files in binary mode
  219. # If 'binary' option not set, encode/decode data before writing and after reading
  220. try:
  221. with _open_or_die(outfile, 'wb') as fp:
  222. fp.write(data if binary else data.encode())
  223. except:
  224. die(2, f'Failed to write {desc} to file {outfile!r}')
  225. if not (hush or quiet):
  226. msg(f'{capfirst(desc)} written to file {outfile!r}')
  227. return True
  228. if cfg.stdout or outfile in ('', '-'):
  229. do_stdout()
  230. elif sys.stdin.isatty() and not sys.stdout.isatty():
  231. do_stdout()
  232. else:
  233. do_file(outfile, ask_write_prompt)
  234. def get_words_from_file(cfg, infile, desc, quiet=False):
  235. if not quiet:
  236. cfg._util.qmsg(f'Getting {desc} from file ‘{infile}’')
  237. with _open_or_die(infile, 'rb') as fp:
  238. data = fp.read()
  239. try:
  240. words = data.decode().split()
  241. except:
  242. die(1, f'{capfirst(desc)} data must be UTF-8 encoded.')
  243. cfg._util.dmsg('Sanitized input: [{}]'.format(' '.join(words)))
  244. return words
  245. def get_data_from_file(
  246. cfg,
  247. infile,
  248. desc = 'data',
  249. dash = False,
  250. silent = False,
  251. binary = False,
  252. quiet = False):
  253. if not (cfg.quiet or silent or quiet):
  254. cfg._util.qmsg(f'Getting {desc} from file ‘{infile}’')
  255. with _open_or_die(
  256. (0 if dash and infile == '-' else infile),
  257. 'rb',
  258. silent=silent) as fp:
  259. data = fp.read(cfg.max_input_size+1)
  260. if not binary:
  261. data = data.decode()
  262. if len(data) == cfg.max_input_size + 1:
  263. die('MaxInputSizeExceeded',
  264. f'Too much input data! Max input data size: {cfg.max_input_size} bytes')
  265. return data
  266. def get_lines_from_file(
  267. cfg,
  268. fn,
  269. desc = 'data',
  270. trim_comments = False,
  271. quiet = False,
  272. silent = False):
  273. def decrypt_file_maybe():
  274. data = get_data_from_file(cfg, fn, desc=desc, binary=True, quiet=quiet, silent=silent)
  275. from .crypto import Crypto
  276. have_enc_ext = get_extension(fn) == Crypto.mmenc_ext
  277. if have_enc_ext or not is_utf8(data):
  278. m = ('Attempting to decrypt', 'Decrypting')[have_enc_ext]
  279. cfg._util.qmsg(f'{m} {desc} ‘{fn}’')
  280. data = Crypto(cfg).mmgen_decrypt_retry(data, desc)
  281. return data
  282. lines = decrypt_file_maybe().decode().splitlines()
  283. if trim_comments:
  284. lines = strip_comments(lines)
  285. cfg._util.dmsg(f'Got {len(lines)} lines from file ‘{fn}’')
  286. return lines