apt_listchanges.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. # vim:set fileencoding=utf-8 et ts=4 sts=4 sw=4:
  2. #
  3. # apt-listchanges - Show changelog entries between the installed versions
  4. # of a set of packages and the versions contained in
  5. # corresponding .deb files
  6. #
  7. # Copyright (C) 2000-2006 Matt Zimmerman <mdz@debian.org>
  8. # Copyright (C) 2006 Pierre Habouzit <madcoder@debian.org>
  9. # Copyright (C) 2016 Robert Luberda <robert@debian.org>
  10. #
  11. # This program is free software; you can redistribute it and/or modify
  12. # it under the terms of the GNU General Public License as published by
  13. # the Free Software Foundation; either version 2 of the License, or
  14. # (at your option) any later version.
  15. #
  16. # This program is distributed in the hope that it will be useful,
  17. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. # GNU General Public License for more details.
  20. #
  21. # You should have received a copy of the GNU General Public
  22. # License along with this program; if not, write to the Free
  23. # Software Foundation, Inc., 59 Temple Place, Suite 330, Boston,
  24. # MA 02111-1307 USA
  25. #
  26. import sys
  27. import os
  28. import re
  29. import subprocess
  30. import locale
  31. import email.message
  32. import email.header
  33. import email.charset
  34. import io
  35. import pwd
  36. import shlex
  37. import tempfile
  38. import ALCLog
  39. import ALChacks
  40. from ALChacks import _
  41. # TODO:
  42. # newt-like frontend, or maybe some GUI bit
  43. # keep track of tar/dpkg-deb errors like in pre-2.0
  44. BREAK_APT_EXIT_CODE=10
  45. def confirm_or_exit(config, frontend):
  46. if not config.confirm:
  47. return
  48. try:
  49. if not frontend.confirm():
  50. ALCLog.error(_('Aborting'))
  51. sys.exit(BREAK_APT_EXIT_CODE)
  52. except (KeyboardInterrupt, EOFError):
  53. sys.exit(BREAK_APT_EXIT_CODE)
  54. except Exception as ex:
  55. ALCLog.error(_("Confirmation failed: %s") % str(ex))
  56. sys.exit(1)
  57. def mail_changes(config, changes, subject):
  58. ALCLog.info(_("Mailing %(address)s: %(subject)s") %
  59. {'address': config.email_address,
  60. 'subject': subject})
  61. charset = email.charset.Charset('utf-8')
  62. charset.body_encoding = '8bit'
  63. charset.header_encoding = email.charset.QP
  64. message = email.message.Message()
  65. if config.email_format == 'html':
  66. changes = html(config).convert_to_html(subject, changes)
  67. message['Content-Type'] = 'text/html; charset=utf-8'
  68. message['Auto-Submitted'] = 'auto-generated'
  69. message['Subject'] = email.header.Header(subject, charset)
  70. message['To'] = config.email_address
  71. message.set_payload(changes, charset)
  72. subprocess.run(['/usr/sbin/sendmail', '-oi', '-t'], input=message.as_bytes(), check=True)
  73. ''' Check if the mail frontend is usable. When the second parameter is given
  74. print an appropriate error message '''
  75. def can_send_emails(config, replacementFrontend = None):
  76. if not os.path.exists("/usr/sbin/sendmail"):
  77. if replacementFrontend:
  78. ALCLog.error(_("The mail frontend needs an installed 'sendmail', using %s")
  79. % replacementFrontend)
  80. return False
  81. if not config.email_address:
  82. if replacementFrontend:
  83. ALCLog.error(_("The mail frontend needs an e-mail address to be configured, using %s")
  84. % replacementFrontend)
  85. return False
  86. return True
  87. ''' Exception class to notify callers of make_frontend() that invalid frontend
  88. was given in the configuration'''
  89. class EUnknownFrontend(Exception):
  90. pass
  91. def _select_frontend(config, frontends):
  92. ''' Utility function used for testing purposes '''
  93. prompt = "\n" + _("Available apt-listchanges frontends:") + "\n" + \
  94. "".join([" %d. %s\n"%(i+1,frontends[i]) for i in range(0, len(frontends))]) + \
  95. _("Choose a frontend by entering its number: ")
  96. for i in (1,2,3):
  97. try:
  98. response = ttyconfirm(config).ttyask(prompt)
  99. if not response:
  100. break
  101. return frontends[int(response)-1]
  102. except Exception as ex:
  103. ALCLog.error(_("Error: %s") % str(ex))
  104. ALCLog.info(_("Using default frontend: %s") % config.frontend)
  105. return config.frontend
  106. def make_frontend(config, packages_count):
  107. frontends = { 'text' : text_frd,
  108. 'pager' : pager_frd,
  109. 'debconf': debconf_frd,
  110. 'mail' : mail_frd,
  111. 'browser' : browser_frd,
  112. 'xterm-pager' : xterm_pager_frd,
  113. 'xterm-browser' : xterm_browser_frd,
  114. 'gtk' : None, # handled below
  115. 'none' : None }
  116. if config.select_frontend: # For testing purposes
  117. name = _select_frontend(config, sorted(list(frontends.keys())))
  118. else:
  119. name = config.frontend
  120. if name == 'none':
  121. return None
  122. # If user does not want any messages force either the mail frontend
  123. # or no frontend at all if mail is not usable
  124. if config.quiet >= 2:
  125. if can_send_emails(config):
  126. name = 'mail'
  127. else:
  128. return None
  129. # If apt is in quiet (loggable) mode, we should make our output
  130. # loggable too unless the mail frontend is used (see #788059)
  131. elif config.quiet == 1:
  132. if name != 'mail' or not can_send_emails(config, 'text'):
  133. name = 'text'
  134. # Non-quiet mode
  135. else:
  136. if name == "mail" and not can_send_emails(config, 'pager'):
  137. name = 'pager'
  138. if name in ('gtk', 'xterm-pager', 'xterm-browser') and "DISPLAY" not in os.environ:
  139. name = name[6:] if name.startswith('xterm-') else 'pager'
  140. ALCLog.error(_("$DISPLAY is not set, falling back to %s")
  141. % (name))
  142. # TODO: it would probably be nice to have a frontends subdir and
  143. # import from that. that would mean a uniform mechanism for all
  144. # frontends (that would become small files inside
  145. if name == "gtk":
  146. try:
  147. gtk = __import__("AptListChangesGtk")
  148. frontends[name] = gtk.gtk_frd
  149. except ImportError as e:
  150. ALCLog.error(_("The gtk frontend needs a working python3-gi.\n"
  151. "Those imports can not be found. Falling back "
  152. "to pager.\n"
  153. "The error is: %s") % e)
  154. name = 'pager'
  155. config.frontend = name
  156. if name not in frontends:
  157. raise EUnknownFrontend
  158. return frontends[name](config, packages_count)
  159. class base(object):
  160. def __init__(self, config, *args):
  161. super().__init__()
  162. self.config = config
  163. def _render(self, text):
  164. return text
  165. class titled(base):
  166. def __init__(self, *args):
  167. super().__init__(*args)
  168. self.title = 'apt-listchanges output'
  169. def set_title(self, title):
  170. self.title = title
  171. class frontend(titled):
  172. def __init__(self, config, packages_count):
  173. super().__init__(config, packages_count)
  174. self.packages_count = packages_count
  175. def update_progress(self):
  176. pass
  177. def progress_done(self):
  178. pass
  179. def display_output(self, text):
  180. pass
  181. def confirm(self):
  182. return True
  183. class debconf_frd(frontend):
  184. def display_output(self, text):
  185. import socket
  186. import debconf as dc
  187. if 'DEBIAN_FRONTEND' not in os.environ or os.environ['DEBIAN_FRONTEND'] != 'passthrough':
  188. return
  189. sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM, 0)
  190. sock.connect(os.environ['DEBCONF_PIPE'])
  191. dcfd = sock.makefile()
  192. sock.close()
  193. db = dc.Debconf(read=dcfd, write=dcfd)
  194. tmp = tempfile.NamedTemporaryFile(prefix="apt-listchanges-tmp")
  195. os.fchmod(tmp.fileno(), 0o644)
  196. tmp.write(b'''Template: apt-listchanges/info
  197. Type: title
  198. Description: NEWS
  199. Template: apt-listchanges/title
  200. Type: title
  201. Description: ${title}
  202. Template: apt-listchanges/news
  203. Type: note
  204. Description: ${packages_count} packages\n''')
  205. for line in text.split('\n'):
  206. if line.strip():
  207. tmp.write(' ' + line + '\n')
  208. else:
  209. tmp.write(' .\n')
  210. tmp.flush()
  211. db.command('x_loadtemplatefile', tmp.name)
  212. tmp.close()
  213. db.info('apt-listchanges/info')
  214. db.subst('apt-listchanges/title', 'title', self.title)
  215. db.subst('apt-listchanges/news', 'packages_count', self.packages_count)
  216. db.settitle('apt-listchanges/title')
  217. db.fset('apt-listchanges/news', 'seen', 'false')
  218. db.input('high', 'apt-listchanges/news')
  219. db.go()
  220. dcfd.close()
  221. class ttyconfirm(base):
  222. def ttyask(self, prompt):
  223. if sys.stdin.isatty() and sys.stdout.isatty():
  224. return input(prompt).rstrip()
  225. tty = open('/dev/tty', 'rb+', buffering=0)
  226. enc = ALChacks.system_encoding()
  227. tty.write(enc.to_bytes(prompt))
  228. tty.flush()
  229. return enc.from_bytes(tty.readline()).rstrip()
  230. def confirm(self):
  231. response = self.ttyask('apt-listchanges: ' + _('Do you want to continue? [Y/n] '))
  232. return response == '' or re.search(locale.nl_langinfo(locale.YESEXPR),
  233. response)
  234. class simpleprogress(base):
  235. def update_progress(self):
  236. if self.config.quiet > 1:
  237. return
  238. if not hasattr(self, 'message_printed'):
  239. self.message_printed = 1
  240. ALCLog.info(_("Reading changelogs") + "...")
  241. def progress_done(self):
  242. pass
  243. class mail_frd(simpleprogress, frontend):
  244. pass
  245. class prepend_title(titled):
  246. def _render(self, text):
  247. return self.title + '\n' + (len(self.title) * '-') + \
  248. '\n\n' + text
  249. class text_frd(prepend_title, simpleprogress, ttyconfirm, frontend):
  250. def display_output(self, text):
  251. sys.stdout.write(ALChacks.system_encoding().as_string(self._render(text)))
  252. class fancyprogress(base):
  253. def update_progress(self):
  254. if not hasattr(self, 'progress'):
  255. # First call
  256. self.progress = 0
  257. self.line_length = 0
  258. self.progress += 1
  259. line = _("Reading changelogs") + "... %d%%" % (self.progress * 100 / self.packages_count)
  260. self.line_length = len(line)
  261. sys.stdout.write(line + '\r')
  262. sys.stdout.flush()
  263. def progress_done(self):
  264. if hasattr(self, 'line_length'):
  265. sys.stdout.write(' ' * self.line_length + '\r')
  266. sys.stdout.write(_("Reading changelogs") + "... " + _("Done") + "\n")
  267. sys.stdout.flush()
  268. class runcommand(base):
  269. def __init__(self, *args):
  270. super().__init__(*args)
  271. self.wait = True
  272. self.suffix = ''
  273. # Derived classes should set enc to system_encoding() or utf8_encoding() from ALChacks
  274. self.enc = None
  275. def display_output(self, text):
  276. # Note: the following fork() call is needed to have temporary file deleted
  277. # after the process created by Popen finishes.
  278. if not self.wait and os.fork() != 0:
  279. # We are the parent, return.
  280. return
  281. tmp = tempfile.NamedTemporaryFile(prefix="apt-listchanges-tmp", suffix=self.suffix, dir=self.get_tmpdir())
  282. tmp.write(self.enc.to_bytes(self._render(text)))
  283. tmp.flush()
  284. self.fchown_tmpfile(tmp.fileno())
  285. process = subprocess.Popen(self.get_command() + [tmp.name], preexec_fn=self.get_preexec_fn(), env=self.get_environ())
  286. status = process.wait()
  287. self._close_temp_file(tmp)
  288. if status != 0:
  289. raise OSError(_("Command %(cmd)s exited with status %(status)d")
  290. % {'cmd': str(process.args), 'status': status})
  291. if not self.wait:
  292. # We are a child; exit
  293. sys.exit(0)
  294. def _close_temp_file(self, tmp):
  295. # Explicitly close the temporary file to ignore errors if the file
  296. # has been removed already, see bug#772663
  297. try:
  298. tmp.close()
  299. except FileNotFoundError:
  300. pass
  301. def get_command(self):
  302. return self.command
  303. # Interface functions for dropping root privileges
  304. def fchown_tmpfile(self, fileno):
  305. pass
  306. def get_tmpdir(self):
  307. return None
  308. def get_preexec_fn(self):
  309. return None
  310. def get_environ(self):
  311. return None
  312. class runcommand_drop_privs(runcommand):
  313. def __init__(self, *args):
  314. super().__init__(*args)
  315. self._user_pw = self._find_user_pw()
  316. self._tmpdir = self._find_tmpdir()
  317. if self.config.debug and self._user_pw:
  318. ALCLog.debug(_("Found user: %(user)s, temporary directory: %(dir)s")
  319. % {'user': self._user_pw.pw_name, 'dir': self._tmpdir})
  320. def fchown_tmpfile(self, fileno):
  321. if self._user_pw:
  322. os.fchown(fileno, self._user_pw.pw_uid, self._user_pw.pw_gid)
  323. def get_tmpdir(self):
  324. return self._tmpdir
  325. def get_preexec_fn(self):
  326. if not self._user_pw:
  327. return None
  328. def preexec():
  329. try:
  330. os.setgid(self._user_pw.pw_gid);
  331. os.setuid(self._user_pw.pw_uid);
  332. except Exception as ex:
  333. ALCLog.error(_("Error: %s") % str(ex))
  334. return preexec
  335. def get_environ(self):
  336. if not self._user_pw:
  337. return None
  338. newenv = os.environ
  339. newenv['HOME'] = self._user_pw.pw_dir
  340. newenv['SHELL'] = self._user_pw.pw_shell
  341. for envvar in ('USERNAME', 'USER', 'LOGNAME'):
  342. newenv[envvar] = self._user_pw.pw_name
  343. if self._tmpdir:
  344. for envvar in ('TMPDIR', 'TMP', 'TEMPDIR', 'TEMP'):
  345. newenv[envvar] = self._tmpdir
  346. return newenv
  347. def _find_user_pw(self):
  348. if os.getuid() != 0:
  349. return None
  350. pwdata = None
  351. for envvar in ('APT_LISTCHANGES_USER', 'SUDO_USER', 'USERNAME'):
  352. if envvar in os.environ:
  353. try:
  354. user = os.environ[envvar]
  355. pwdata = pwd.getpwnam(user) if not user.isdigit() else pwd.getpwuid(user)
  356. break # check the first environment variable only
  357. except Exception as ex:
  358. raise RuntimeError(_("Error getting user from variable '%(envvar)s': %(errmsg)s")
  359. % {'envvar': envvar, 'errmsg': str(ex)}) from ex
  360. if pwdata and pwdata.pw_uid:
  361. return pwdata
  362. ALCLog.warning(_("Cannot find suitable user to drop root privileges"))
  363. return None
  364. def _find_tmpdir(self):
  365. if not self._user_pw:
  366. return None
  367. tmpdir = tempfile.gettempdir()
  368. flags = os.R_OK | os.W_OK | os.X_OK
  369. os.setreuid(self._user_pw.pw_uid, 0)
  370. try:
  371. # check the default directory from $TMPDIR variable
  372. if os.access(tmpdir, flags):
  373. return tmpdir
  374. checked_tmpdirs = [tmpdir]
  375. # replace pam_tmpdir's directory /tmp/user/0 into e.g. /tmp/user/1000
  376. if tmpdir.endswith("/0"):
  377. tmpdir = tmpdir[0:-1] + str(self._user_pw.pw_uid)
  378. if os.access(tmpdir, flags):
  379. return tmpdir
  380. checked_tmpdirs.append(tmpdir)
  381. # finally try hard-coded location
  382. if tmpdir != "/tmp":
  383. tmpdir="/tmp"
  384. if os.access(tmpdir, flags):
  385. return tmpdir
  386. checked_tmpdirs.append(tmpdir)
  387. raise RuntimeError(_("None of the following directories is accessible"
  388. " by user %(user)s: %(dirs)s")
  389. %{'user': self._user_pw.pw_name,
  390. 'dirs': str(checked_tmpdirs)})
  391. finally:
  392. os.setuid(0)
  393. class xterm(runcommand_drop_privs):
  394. def __init__(self, *args):
  395. super().__init__(*args)
  396. self.mode = os.P_NOWAIT
  397. self.wait = False
  398. self.xterm = shlex.split(self.config.get('xterm', 'x-terminal-emulator'))
  399. def get_command(self):
  400. return self.xterm + ['-T', self.title, '-e'] + self.command
  401. class pager_frd(runcommand, prepend_title, ttyconfirm, fancyprogress, frontend):
  402. def __init__(self, *args):
  403. super().__init__(*args)
  404. if not 'LESS' in os.environ:
  405. os.environ['LESS'] = "-P?e(" + _("press q to quit") + ")"
  406. self.command = shlex.split(self.config.get('pager', 'sensible-pager'))
  407. self.suffix = '.txt'
  408. self.enc = ALChacks.system_encoding()
  409. class xterm_pager_frd(xterm, pager_frd):
  410. pass
  411. class html(titled):
  412. # LP bug-closing format requires the colon after "LP", but many people
  413. # say "LP #123456" when talking informally about bugs.
  414. lp_bug_stanza_re = re.compile(r'(?:lp:?\s+\#\d+(?:,\s*\#\d+)*)', re.I)
  415. lp_bug_re = re.compile('(?P<linktext>#(?P<bugnum>\d+))', re.I)
  416. lp_bug_fmt = r'<a href="https://launchpad.net/bugs/\g<bugnum>">\g<linktext></a>'
  417. bug_stanza_re = re.compile(r'(?:closes:\s*(?:bug)?\#?\s?\d+(?:,\s*(?:bug)?\#?\s?\d+)*|(?<!">)#\d+)', re.I)
  418. bug_re = re.compile('(?P<linktext>#?(?P<bugnum>\d+))', re.I)
  419. bug_fmt = r'<a href="https://bugs.debian.org/\g<bugnum>">\g<linktext></a>'
  420. cve_re = re.compile(r'\bC(VE|AN)-(19|20|21)\d\d-\d{4,7}\b')
  421. cve_fmt = r'<a href="https://cve.mitre.org/cgi-bin/cvename.cgi?name=\g<0>">\g<0></a>'
  422. # regxlib.com
  423. email_re = re.compile(r'([a-zA-Z0-9_\+\-\.]+)@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\]?)')
  424. email_fmt = r'<a href="mailto:\g<0>">\g<0></a>'
  425. url_re = re.compile(r'(ht|f)tps?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(:[a-zA-Z0-9]*)?/?([a-zA-Z0-9\-\._\?\,\'/\\\+&amp;%\$#\=~])*')
  426. url_fmt = r'<a href="\g<0>">\g<0></a>'
  427. def convert_to_html(self, title, text):
  428. self.set_title(title)
  429. return self._render(text)
  430. def _render(self, text):
  431. htmltext = io.StringIO()
  432. htmltext.write('''<html>
  433. <head>
  434. <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
  435. <title>''')
  436. htmltext.write(self.title)
  437. htmltext.write('''</title>
  438. </head>
  439. <body>
  440. <pre>''')
  441. for line in text.split('\n'):
  442. line = line.encode('utf-8').replace(
  443. b'&', b'&amp;').replace(
  444. b'<', b'&lt;').replace(
  445. b'>', b'&gt;').decode('utf-8')
  446. line = self.url_re.sub(self.url_fmt, line)
  447. line = self.lp_bug_stanza_re.sub(lambda m: self.lp_bug_re.sub(self.lp_bug_fmt, m.group(0)), line)
  448. line = self.bug_stanza_re.sub(lambda m: self.bug_re.sub(self.bug_fmt, m.group(0)), line)
  449. line = self.cve_re.sub(self.cve_fmt, line)
  450. line = self.email_re.sub(self.email_fmt, line)
  451. htmltext.write(line + '\n')
  452. htmltext.write('</pre></body></html>')
  453. return htmltext.getvalue()
  454. class browser_frd(html, runcommand_drop_privs, ttyconfirm, fancyprogress, frontend):
  455. def __init__(self, *args):
  456. super().__init__(*args)
  457. self.command = shlex.split(self.config.get('browser', 'sensible-browser'))
  458. self.suffix = '.html'
  459. self.enc = ALChacks.utf8_encoding()
  460. class xterm_browser_frd(xterm, browser_frd):
  461. pass