cfg.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2021 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. cfg.py: API for the MMGen runtime configuration file and related files
  20. """
  21. # NB: This module is used by override_from_cfg_file(), which is called before override_from_env()
  22. # during init, so global config vars that are set from the environment (such as g.test_suite)
  23. # cannot be used here.
  24. import sys,os,re,hashlib
  25. from collections import namedtuple
  26. from .globalvars import *
  27. from .util import *
  28. def cfg_file(id_str):
  29. return CfgFile.get_cls_by_id(id_str)()
  30. class CfgFile(object):
  31. cur_ver = 2
  32. ver = None
  33. write_ok = False
  34. warn_missing = True
  35. write_metadata = False
  36. fn_base = g.proj_name.lower() + '.cfg'
  37. file_not_found_fs = 'WARNING: {} not found at {!r}'
  38. def __init__(self):
  39. self.fn = os.path.join(self.fn_dir,self.fn_base)
  40. self.data = self.get_data()
  41. def get_data(self):
  42. try:
  43. return open(self.fn).read().splitlines()
  44. except:
  45. if self.warn_missing:
  46. msg(self.file_not_found_fs.format(self.desc,self.fn))
  47. return ''
  48. def copy_data(self):
  49. assert self.write_ok, 'writing to file {!r} not allowed!'.format(self.fn)
  50. src = cfg_file('sys')
  51. if src.data:
  52. data = src.data + src.make_metadata() if self.write_metadata else src.data
  53. try:
  54. open(self.fn,'w').write('\n'.join(data)+'\n')
  55. os.chmod(self.fn,0o600)
  56. except:
  57. die(2,'ERROR: unable to write to {!r}'.format(self.fn))
  58. def parse_var(self,line,lineno):
  59. try:
  60. m = re.match(r'(\w+)(\s+(\S+)|(\s+\w+:\S+)+)$',line) # allow multiple colon-separated values
  61. return (m[1], dict([i.split(':') for i in m[2].split()]) if m[4] else m[3])
  62. except:
  63. raise CfgFileParseError('Parse error in file {!r}, line {}'.format(self.fn,lineno))
  64. def parse(self):
  65. cdata = namedtuple('cfg_var',['name','value','lineno'])
  66. def do_parse():
  67. for n,line in enumerate(self.data,1):
  68. line = strip_comments(line)
  69. if line == '':
  70. continue
  71. yield cdata(*self.parse_var(line,n),n)
  72. return do_parse()
  73. @classmethod
  74. def get_cls_by_id(self,id_str):
  75. d = {
  76. 'usr': CfgFileUsr,
  77. 'sys': CfgFileSampleSys,
  78. 'sample': CfgFileSampleUsr,
  79. 'dist': CfgFileSampleDist,
  80. }
  81. return d[id_str]
  82. class CfgFileSample(CfgFile):
  83. @classmethod
  84. def cls_make_metadata(cls,data):
  85. return ['# Version {} {}'.format(cls.cur_ver,cls.compute_chksum(data))]
  86. @staticmethod
  87. def compute_chksum(data):
  88. return hashlib.new('ripemd160','\n'.join(data).encode()).hexdigest()
  89. @property
  90. def computed_chksum(self):
  91. return type(self).compute_chksum(self.data)
  92. def parse(self,parse_vars=False):
  93. """
  94. The config file template contains some 'magic':
  95. - lines must either be empty or begin with '# '
  96. - each commented chunk must end with a parsable cfg variable line
  97. - chunks are delimited by one or more blank lines
  98. - lines beginning with '##' are ignored
  99. - everything up to first line beginning with '##' is ignored
  100. - last line is metadata line of the form '# Version VER_NUM HASH'
  101. """
  102. cdata = namedtuple('chunk_data',['name','lines','lineno','parsed'])
  103. def process_chunk(chunk,n):
  104. last_line = chunk[-1].split()
  105. return cdata(
  106. last_line[1],
  107. chunk,
  108. n,
  109. self.parse_var(' '.join(last_line[1:]),n) if parse_vars else None,
  110. )
  111. def get_chunks(lines):
  112. hdr = True
  113. chunk = []
  114. in_chunk = False
  115. for n,line in enumerate(lines,1):
  116. if line.startswith('##'):
  117. hdr = False
  118. continue
  119. if hdr:
  120. continue
  121. if line == '':
  122. in_chunk = False
  123. elif line.startswith('# '):
  124. if in_chunk == False:
  125. if chunk:
  126. yield process_chunk(chunk,last_nonblank)
  127. chunk = [line]
  128. in_chunk = True
  129. else:
  130. chunk.append(line)
  131. last_nonblank = n
  132. else:
  133. die(2,'parse error in file {!r}, line {}'.format(self.fn,n))
  134. if chunk:
  135. yield process_chunk(chunk,last_nonblank)
  136. return list(get_chunks(self.data))
  137. class CfgFileUsr(CfgFile):
  138. desc = 'user configuration file'
  139. warn_missing = False
  140. fn_dir = g.data_dir_root
  141. write_ok = True
  142. def __init__(self):
  143. super().__init__()
  144. if not self.data:
  145. self.copy_data()
  146. class CfgFileSampleDist(CfgFileSample):
  147. desc = 'source distribution configuration file'
  148. fn_dir = 'data_files'
  149. class CfgFileSampleSys(CfgFileSample):
  150. desc = 'system sample configuration file'
  151. test_fn_subdir = 'usr.local.share'
  152. @property
  153. def fn_dir(self):
  154. if os.getenv('MMGEN_TEST_SUITE_CFGTEST'):
  155. return os.path.join(g.data_dir_root,self.test_fn_subdir)
  156. else:
  157. return g.shared_data_path
  158. def make_metadata(self):
  159. return ['# Version {} {}'.format(self.cur_ver,self.computed_chksum)]
  160. class CfgFileSampleUsr(CfgFileSample):
  161. desc = 'sample configuration file'
  162. warn_missing = False
  163. fn_base = g.proj_name.lower() + '.cfg.sample'
  164. fn_dir = g.data_dir_root
  165. write_ok = True
  166. chksum = None
  167. write_metadata = True
  168. details_confirm_prompt = 'View details?'
  169. out_of_date_fs = 'File {!r} is out of date - replacing'
  170. altered_by_user_fs = 'File {!r} was altered by user - replacing'
  171. def __init__(self):
  172. super().__init__()
  173. src = cfg_file('sys')
  174. if not src.data:
  175. return
  176. if self.data:
  177. if self.parse_metadata():
  178. if self.chksum == self.computed_chksum:
  179. diff = self.diff(self.parse(),src.parse())
  180. if not diff:
  181. return
  182. self.show_changes(diff)
  183. else:
  184. msg(self.altered_by_user_fs.format(self.fn))
  185. else:
  186. msg(self.out_of_date_fs.format(self.fn))
  187. self.copy_data()
  188. def parse_metadata(self):
  189. if self.data:
  190. m = re.match(r'# Version (\d+) ([a-f0-9]{40})$',self.data[-1])
  191. if m:
  192. self.ver = m[1]
  193. self.chksum = m[2]
  194. self.data = self.data[:-1] # remove metadata line
  195. return True
  196. def diff(self,a_tup,b_tup): # a=user, b=system
  197. a = [i.name for i in a_tup]#[3:] # Debug
  198. b = [i.name for i in b_tup]#[:-2] # Debug
  199. removed = set(a) - set(b)
  200. added = set(b) - set(a)
  201. if removed or added:
  202. return {
  203. 'removed': [i for i in a_tup if i.name in removed],
  204. 'added': [i for i in b_tup if i.name in added],
  205. }
  206. else:
  207. return None
  208. def show_changes(self,diff):
  209. ymsg('Warning: configuration file options have changed!\n')
  210. m1 = ' The following option{} been {}:\n {}\n'
  211. m2 = """
  212. The following removed option{} set in {!r}
  213. and must be deleted or commented out:
  214. {}
  215. """
  216. for desc in ('added','removed'):
  217. data = diff[desc]
  218. if data:
  219. opts = fmt_list([i.name for i in data],fmt='bare')
  220. msg(m1.format(suf(data,verb='has'),desc,opts))
  221. if desc == 'removed' and data:
  222. uc = cfg_file('usr')
  223. usr_names = [i.name for i in uc.parse()]
  224. rm_names = [i.name for i in data]
  225. bad = sorted(set(usr_names).intersection(rm_names))
  226. if bad:
  227. ymsg(fmt(m2,' ').format(suf(bad,verb='is'),uc.fn,' '+fmt_list(bad,fmt='bare')))
  228. while True:
  229. if not keypress_confirm(self.details_confirm_prompt,no_nl=True):
  230. return
  231. def get_details():
  232. for desc,data in diff.items():
  233. sep,sep2 = ('\n ','\n\n ')
  234. if data:
  235. yield (
  236. '{} section{}:'.format(capfirst(desc),suf(data))
  237. + sep2
  238. + sep2.join(['{}'.format(sep.join(v.lines)) for v in data])
  239. )
  240. do_pager(
  241. 'CONFIGURATION FILE CHANGES\n\n'
  242. + '\n\n'.join(get_details()) + '\n'
  243. )