cfgfile.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. #!/usr/bin/env python3
  2. #
  3. # MMGen Wallet, a terminal-based cryptocurrency wallet
  4. # Copyright (C)2013-2025 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. cfgfile: API for the MMGen runtime configuration file and related files
  20. """
  21. import os, re
  22. from collections import namedtuple
  23. from .cfg import gc
  24. from .util import msg, ymsg, suf, fmt, fmt_list, oneshot_warning, strip_comment, capfirst, die
  25. def mmgen_cfg_file(cfg, id_str):
  26. return cfg_file.get_cls_by_id(id_str)(cfg)
  27. class cfg_file:
  28. cur_ver = 2
  29. ver = None
  30. write_ok = False
  31. warn_missing = True
  32. write_metadata = False
  33. line_data = namedtuple('cfgfile_line', ['name', 'value', 'lineno', 'chunk'])
  34. fn_base = 'mmgen.cfg'
  35. class warn_missing_file(oneshot_warning):
  36. color = 'yellow' # has no effect, as color not initialized yet
  37. message = '{} not found at {!r}'
  38. def get_data(self, fn):
  39. try:
  40. with open(fn) as fp:
  41. return fp.read().splitlines()
  42. except:
  43. if self.warn_missing:
  44. self.warn_missing_file(div=fn, fmt_args=(self.desc, fn))
  45. return ''
  46. def copy_system_data(self, fn):
  47. assert self.write_ok, f'writing to file {fn!r} not allowed!'
  48. src = mmgen_cfg_file(self.cfg, 'sys')
  49. if src.data:
  50. data = src.data + src.make_metadata() if self.write_metadata else src.data
  51. try:
  52. with open(fn, 'w') as fp:
  53. fp.write('\n'.join(data)+'\n')
  54. os.chmod(fn, 0o600)
  55. except:
  56. die(2, f'ERROR: unable to write to {fn!r}')
  57. def parse_value(self, value, refval):
  58. match refval:
  59. case dict(): # expect one or more colon-separated values:
  60. if m := re.fullmatch(r'((\s+\w+:\S+)+)', ' ' + value):
  61. return dict([i.split(':') for i in m[1].split()])
  62. case list() | tuple(): # expect single value or list:
  63. if m := re.fullmatch(r'((\s+\S+)+)', ' ' + value):
  64. ret = m[1].split()
  65. return ret if isinstance(refval, list) else tuple(ret)
  66. case _:
  67. return value
  68. def get_lines(self):
  69. def gen_lines():
  70. for lineno, line in enumerate(self.data, 1):
  71. if (line := strip_comment(line)) == '':
  72. continue
  73. if m := re.fullmatch(r'(\w+)(\s+)(.*)', line):
  74. yield self.line_data(m[1], m[3], lineno, None)
  75. else:
  76. die('CfgFileParseError', f'Parse error in file {self.fn!r}, line {lineno}')
  77. return gen_lines()
  78. @classmethod
  79. def get_cls_by_id(cls, id_str):
  80. d = {
  81. 'usr': CfgFileUsr,
  82. 'sys': CfgFileSampleSys,
  83. 'sample': CfgFileSampleUsr}
  84. return d[id_str]
  85. class cfg_file_sample(cfg_file):
  86. @classmethod
  87. def cls_make_metadata(cls, data):
  88. return [f'# Version {cls.cur_ver} {cls.compute_chksum(data)}']
  89. @staticmethod
  90. def compute_chksum(data):
  91. import hashlib
  92. return hashlib.new('ripemd160', '\n'.join(data).encode()).hexdigest()
  93. @property
  94. def computed_chksum(self):
  95. return type(self).compute_chksum(self.data)
  96. def get_lines(self):
  97. """
  98. The config file template contains some 'magic':
  99. - lines must either be empty or begin with '# '
  100. - each commented chunk must end with a parsable cfg variable line
  101. - chunks are delimited by one or more blank lines
  102. - lines beginning with '##' are ignored
  103. - everything up to first line beginning with '##' is ignored
  104. - last line is metadata line of the form '# Version VER_NUM HASH'
  105. """
  106. def process_chunk(chunk, lineno):
  107. m = re.fullmatch(r'(#\s*)(\w+)(\s+)(.*)', chunk[-1])
  108. if m:
  109. return self.line_data(m[2], m[4], lineno, chunk)
  110. else:
  111. die('CfgFileParseError', f'Parse error in file {self.fn!r}, line {lineno}')
  112. def gen_chunks(lines):
  113. hdr = True
  114. chunk = []
  115. in_chunk = False
  116. last_nonblank = 0
  117. for lineno, line in enumerate(lines, 1):
  118. if line.startswith('##'):
  119. hdr = False
  120. continue
  121. if hdr:
  122. continue
  123. if line == '':
  124. in_chunk = False
  125. elif line.startswith('#'):
  126. if in_chunk is False:
  127. if chunk:
  128. yield process_chunk(chunk, last_nonblank)
  129. chunk = [line]
  130. in_chunk = True
  131. else:
  132. chunk.append(line)
  133. last_nonblank = lineno
  134. else:
  135. die('CfgFileParseError', f'Parse error in file {self.fn!r}, line {lineno}')
  136. if chunk:
  137. yield process_chunk(chunk, last_nonblank)
  138. return list(gen_chunks(self.data))
  139. class CfgFileUsr(cfg_file):
  140. desc = 'user configuration file'
  141. warn_missing = False
  142. write_ok = True
  143. def __init__(self, cfg):
  144. self.cfg = cfg
  145. self.fn = os.path.join(cfg.data_dir_root, self.fn_base)
  146. self.data = self.get_data(self.fn)
  147. if not self.data:
  148. self.copy_system_data(self.fn)
  149. class CfgFileSampleSys(cfg_file_sample):
  150. desc = 'system sample configuration file'
  151. test_fn_subdir = 'usr.local.share'
  152. def __init__(self, cfg):
  153. self.cfg = cfg
  154. if self.cfg.test_suite_cfgtest:
  155. self.fn = os.path.join(cfg.data_dir_root, self.test_fn_subdir, self.fn_base)
  156. with open(self.fn) as fp:
  157. self.data = fp.read().splitlines()
  158. else:
  159. # self.fn is used for error msgs only, so file need not exist on filesystem
  160. self.fn = os.path.join(os.path.dirname(__file__), 'data', self.fn_base)
  161. self.data = gc.read_mmgen_data_file(filename=self.fn_base).splitlines()
  162. def make_metadata(self):
  163. return [f'# Version {self.cur_ver} {self.computed_chksum}']
  164. class CfgFileSampleUsr(cfg_file_sample):
  165. desc = 'sample configuration file'
  166. warn_missing = False
  167. write_ok = True
  168. chksum = None
  169. write_metadata = True
  170. details_confirm_prompt = 'View details?'
  171. out_of_date_fs = 'File {!r} is out of date - replacing'
  172. altered_by_user_fs = 'File {!r} was altered by user - replacing'
  173. def __init__(self, cfg):
  174. self.cfg = cfg
  175. self.fn = os.path.join(cfg.data_dir_root, f'{self.fn_base}.sample')
  176. self.data = self.get_data(self.fn)
  177. src = mmgen_cfg_file(cfg, 'sys')
  178. if not src.data:
  179. return
  180. if self.data:
  181. if self.parse_metadata():
  182. if self.chksum == self.computed_chksum:
  183. diff = self.diff(self.get_lines(), src.get_lines())
  184. if not diff:
  185. return
  186. self.show_changes(diff)
  187. else:
  188. msg(self.altered_by_user_fs.format(self.fn))
  189. else:
  190. msg(self.out_of_date_fs.format(self.fn))
  191. self.copy_system_data(self.fn)
  192. def parse_metadata(self):
  193. if self.data:
  194. if m := re.match(r'# Version (\d+) ([a-f0-9]{40})$', self.data[-1]):
  195. self.ver = m[1]
  196. self.chksum = m[2]
  197. self.data = self.data[:-1] # remove metadata line
  198. return True
  199. def diff(self, a_tup, b_tup): # a=user, b=system
  200. a = [i.name for i in a_tup]#[3:] # Debug
  201. b = [i.name for i in b_tup]#[:-2] # Debug
  202. removed = set(a) - set(b)
  203. added = set(b) - set(a)
  204. if removed or added:
  205. return {
  206. 'removed': [i for i in a_tup if i.name in removed],
  207. 'added': [i for i in b_tup if i.name in added]}
  208. else:
  209. return None
  210. def show_changes(self, diff):
  211. ymsg('Warning: configuration file options have changed!\n')
  212. for desc in ('added', 'removed'):
  213. changed_opts = [i.name for i in diff[desc]
  214. # workaround for coin-specific opts previously listed in sample file:
  215. if not (i.name.endswith('_ignore_daemon_version') and desc == 'removed')
  216. ]
  217. if changed_opts:
  218. msg(f' The following option{suf(changed_opts, verb="has")} been {desc}:')
  219. msg(f' {fmt_list(changed_opts, fmt="bare")}\n')
  220. if desc == 'removed':
  221. uc = mmgen_cfg_file(self.cfg, 'usr')
  222. usr_names = [i.name for i in uc.get_lines()]
  223. bad = sorted(set(usr_names).intersection(changed_opts))
  224. if bad:
  225. m = f"""
  226. The following removed option{suf(bad, verb='is')} set in {uc.fn!r}
  227. and must be deleted or commented out:
  228. {' ' + fmt_list(bad, fmt='bare')}
  229. """
  230. ymsg(fmt(m, indent=' ', strip_char='\t'))
  231. from .ui import keypress_confirm, do_pager
  232. while True:
  233. if not keypress_confirm(self.cfg, self.details_confirm_prompt, no_nl=True):
  234. return
  235. def get_details():
  236. for desc, data in diff.items():
  237. sep, sep2 = ('\n ', '\n\n ')
  238. if data:
  239. yield (
  240. f'{capfirst(desc)} section{suf(data)}:'
  241. + sep2
  242. + sep2.join([f'{sep.join(v.chunk)}' for v in data])
  243. )
  244. do_pager(
  245. 'CONFIGURATION FILE CHANGES\n\n'
  246. + '\n\n'.join(get_details()) + '\n'
  247. )