1"""module to handle command files
3@contact: Debian FTP Master <ftpmaster@debian.org>
4@copyright: 2012, Ansgar Burchardt <ansgar@debian.org>
5@copyright: 2023 Emilio Pozuelo Monfort <pochu@debian.org>
6@license: GPL-2+
7"""
9# This program is free software; you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation; either version 2 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License along
20# with this program; if not, write to the Free Software Foundation, Inc.,
21# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
23import apt_pkg
24import os
25import tempfile
27from daklib.config import Config
28from daklib.dak_exceptions import *
29from daklib.dbconn import *
30from daklib.gpg import SignedFile
31from daklib.regexes import re_field_package
32from daklib.textutils import fix_maintainer
33from daklib.utils import gpg_get_key_addresses, send_mail, TemplateSubst
36class CommandError(Exception):
37 pass
40class CommandFile:
41 def __init__(self, filename: str, data: bytes, log=None):
42 if log is None: 42 ↛ 43line 42 didn't jump to line 43, because the condition on line 42 was never true
43 from daklib.daklog import Logger
44 log = Logger()
45 self.cc: list[str] = []
46 self.result = []
47 self.log = log
48 self.filename: str = filename
49 self.data = data
50 self.uploader = None
52 def _check_replay(self, signed_file: SignedFile, session):
53 """check for replays
55 .. note::
57 Will commit changes to the database.
59 :param session: database session
60 """
61 # Mark commands file as seen to prevent replays.
62 signature_history = SignatureHistory.from_signed_file(signed_file)
63 session.add(signature_history)
64 session.commit()
66 def _quote_section(self, section) -> str:
67 lines = []
68 for l in str(section).splitlines():
69 lines.append("> {0}".format(l))
70 return "\n".join(lines)
72 def _evaluate_sections(self, sections, session):
73 session.rollback()
74 try:
75 while True:
76 next(sections)
77 section = sections.section
78 self.result.append(self._quote_section(section))
80 action = section.get('Action', None)
81 if action is None: 81 ↛ 82line 81 didn't jump to line 82, because the condition on line 81 was never true
82 raise CommandError('Encountered section without Action field')
84 if action == 'dm':
85 self.action_dm(self.fingerprint, section, session)
86 elif action == 'dm-remove': 86 ↛ 87line 86 didn't jump to line 87, because the condition on line 86 was never true
87 self.action_dm_remove(self.fingerprint, section, session)
88 elif action == 'dm-migrate': 88 ↛ 89line 88 didn't jump to line 89, because the condition on line 88 was never true
89 self.action_dm_migrate(self.fingerprint, section, session)
90 elif action == 'break-the-archive': 90 ↛ 91line 90 didn't jump to line 91, because the condition on line 90 was never true
91 self.action_break_the_archive(self.fingerprint, section, session)
92 elif action == 'process-upload': 92 ↛ 95line 92 didn't jump to line 95, because the condition on line 92 was never false
93 self.action_process_upload(self.fingerprint, section, session)
94 else:
95 raise CommandError('Unknown action: {0}'.format(action))
97 self.result.append('')
98 except StopIteration:
99 pass
100 finally:
101 session.rollback()
103 def _notify_uploader(self):
104 cnf = Config()
106 bcc = 'X-DAK: dak process-command'
107 if 'Dinstall::Bcc' in cnf: 107 ↛ 108line 107 didn't jump to line 108, because the condition on line 107 was never true
108 bcc = '{0}\nBcc: {1}'.format(bcc, cnf['Dinstall::Bcc'])
110 maint_to = None
111 addresses = gpg_get_key_addresses(self.fingerprint.fingerprint)
112 if len(addresses) > 0: 112 ↛ 115line 112 didn't jump to line 115, because the condition on line 112 was never false
113 maint_to = addresses[0]
115 if self.uploader:
116 try:
117 maint_to = fix_maintainer(self.uploader)[1]
118 except ParseMaintError:
119 self.log.log('ignoring malformed uploader', self.filename)
121 cc = set()
122 for address in self.cc:
123 try:
124 cc.add(fix_maintainer(address)[1])
125 except ParseMaintError:
126 self.log.log('ignoring malformed cc', self.filename)
128 subst = {
129 '__DAK_ADDRESS__': cnf['Dinstall::MyEmailAddress'],
130 '__MAINTAINER_TO__': maint_to,
131 '__CC__': ", ".join(cc),
132 '__BCC__': bcc,
133 '__RESULTS__': "\n".join(self.result),
134 '__FILENAME__': self.filename,
135 }
137 message = TemplateSubst(subst, os.path.join(cnf['Dir::Templates'], 'process-command.processed'))
139 send_mail(message)
141 def evaluate(self) -> bool:
142 """evaluate commands file
144 :return: :const:`True` if the file was processed sucessfully,
145 :const:`False` otherwise
146 """
147 result = True
149 session = DBConn().session()
151 keyrings = session.query(Keyring).filter_by(active=True).order_by(Keyring.priority)
152 keyring_files = [k.keyring_name for k in keyrings]
154 signed_file = SignedFile(self.data, keyring_files)
155 if not signed_file.valid: 155 ↛ 156line 155 didn't jump to line 156, because the condition on line 155 was never true
156 self.log.log(['invalid signature', self.filename])
157 return False
159 self.fingerprint = session.query(Fingerprint).filter_by(fingerprint=signed_file.primary_fingerprint).one()
160 if self.fingerprint.keyring is None: 160 ↛ 161line 160 didn't jump to line 161, because the condition on line 160 was never true
161 self.log.log(['singed by key in unknown keyring', self.filename])
162 return False
163 assert self.fingerprint.keyring.active
165 self.log.log(['processing', self.filename, 'signed-by={0}'.format(self.fingerprint.fingerprint)])
167 with tempfile.TemporaryFile() as fh:
168 fh.write(signed_file.contents)
169 fh.seek(0)
170 sections = apt_pkg.TagFile(fh)
172 try:
173 next(sections)
174 section = sections.section
175 if 'Uploader' in section:
176 self.uploader = section['Uploader']
177 if 'Cc' in section: 177 ↛ 178line 177 didn't jump to line 178, because the condition on line 177 was never true
178 self.cc.append(section['Cc'])
179 # TODO: Verify first section has valid Archive field
180 if 'Archive' not in section: 180 ↛ 181line 180 didn't jump to line 181, because the condition on line 180 was never true
181 raise CommandError('No Archive field in first section.')
183 # TODO: send mail when we detected a replay.
184 self._check_replay(signed_file, session)
186 self._evaluate_sections(sections, session)
187 self.result.append('')
188 except Exception as e:
189 self.log.log(['ERROR', e])
190 self.result.append("There was an error processing this section. No changes were committed.\nDetails:\n{0}".format(e))
191 result = False
193 self._notify_uploader()
195 session.close()
197 return result
199 def _split_packages(self, value: str) -> list[str]:
200 names = value.split()
201 for name in names:
202 if not re_field_package.match(name): 202 ↛ 203line 202 didn't jump to line 203, because the condition on line 202 was never true
203 raise CommandError('Invalid package name "{0}"'.format(name))
204 return names
206 def action_dm(self, fingerprint, section, session) -> None:
207 cnf = Config()
209 if 'Command::DM::AdminKeyrings' not in cnf \ 209 ↛ 212line 209 didn't jump to line 212, because the condition on line 209 was never true
210 or 'Command::DM::ACL' not in cnf \
211 or 'Command::DM::Keyrings' not in cnf:
212 raise CommandError('DM command is not configured for this archive.')
214 allowed_keyrings = cnf.value_list('Command::DM::AdminKeyrings')
215 if fingerprint.keyring.keyring_name not in allowed_keyrings: 215 ↛ 216line 215 didn't jump to line 216, because the condition on line 215 was never true
216 raise CommandError('Key {0} is not allowed to set DM'.format(fingerprint.fingerprint))
218 acl_name = cnf.get('Command::DM::ACL', 'dm')
219 acl = session.query(ACL).filter_by(name=acl_name).one()
221 fpr_hash = section['Fingerprint'].replace(' ', '')
222 fpr = session.query(Fingerprint).filter_by(fingerprint=fpr_hash).first()
223 if fpr is None: 223 ↛ 224line 223 didn't jump to line 224, because the condition on line 223 was never true
224 raise CommandError('Unknown fingerprint {0}'.format(fpr_hash))
225 if fpr.keyring is None or fpr.keyring.keyring_name not in cnf.value_list('Command::DM::Keyrings'):
226 raise CommandError('Key {0} is not in DM keyring.'.format(fpr.fingerprint))
227 addresses = gpg_get_key_addresses(fpr.fingerprint)
228 if len(addresses) > 0: 228 ↛ 231line 228 didn't jump to line 231, because the condition on line 228 was never false
229 self.cc.append(addresses[0])
231 self.log.log(['dm', 'fingerprint', fpr.fingerprint])
232 self.result.append('Fingerprint: {0}'.format(fpr.fingerprint))
233 if len(addresses) > 0: 233 ↛ 237line 233 didn't jump to line 237, because the condition on line 233 was never false
234 self.log.log(['dm', 'uid', addresses[0]])
235 self.result.append('Uid: {0}'.format(addresses[0]))
237 for source in self._split_packages(section.get('Allow', '')):
238 # Check for existance of source package to catch typos
239 if session.query(DBSource).filter_by(source=source).first() is None: 239 ↛ 240line 239 didn't jump to line 240, because the condition on line 239 was never true
240 raise CommandError('Tried to grant permissions for unknown source package: {0}'.format(source))
242 if session.query(ACLPerSource).filter_by(acl=acl, fingerprint=fpr, source=source).first() is None: 242 ↛ 253line 242 didn't jump to line 253, because the condition on line 242 was never false
243 aps = ACLPerSource()
244 aps.acl = acl
245 aps.fingerprint = fpr
246 aps.source = source
247 aps.created_by = fingerprint
248 aps.reason = section.get('Reason')
249 session.add(aps)
250 self.log.log(['dm', 'allow', fpr.fingerprint, source])
251 self.result.append('Allowed: {0}'.format(source))
252 else:
253 self.result.append('Already-Allowed: {0}'.format(source))
255 session.flush()
257 for source in self._split_packages(section.get('Deny', '')):
258 count = session.query(ACLPerSource).filter_by(acl=acl, fingerprint=fpr, source=source).delete()
259 if count == 0: 259 ↛ 260line 259 didn't jump to line 260, because the condition on line 259 was never true
260 raise CommandError('Tried to remove upload permissions for package {0}, '
261 'but no upload permissions were granted before.'.format(source))
263 self.log.log(['dm', 'deny', fpr.fingerprint, source])
264 self.result.append('Denied: {0}'.format(source))
266 session.commit()
268 def _action_dm_admin_common(self, fingerprint, section, session) -> None:
269 cnf = Config()
271 if 'Command::DM-Admin::AdminFingerprints' not in cnf \
272 or 'Command::DM::ACL' not in cnf:
273 raise CommandError('DM admin command is not configured for this archive.')
275 allowed_fingerprints = cnf.value_list('Command::DM-Admin::AdminFingerprints')
276 if fingerprint.fingerprint not in allowed_fingerprints:
277 raise CommandError('Key {0} is not allowed to admin DM'.format(fingerprint.fingerprint))
279 def action_dm_remove(self, fingerprint, section, session) -> None:
280 self._action_dm_admin_common(fingerprint, section, session)
282 cnf = Config()
283 acl_name = cnf.get('Command::DM::ACL', 'dm')
284 acl = session.query(ACL).filter_by(name=acl_name).one()
286 fpr_hash = section['Fingerprint'].replace(' ', '')
287 fpr = session.query(Fingerprint).filter_by(fingerprint=fpr_hash).first()
288 if fpr is None:
289 self.result.append('Unknown fingerprint: {0}\nNo action taken.'.format(fpr_hash))
290 return
292 self.log.log(['dm-remove', fpr.fingerprint])
294 count = 0
295 for entry in session.query(ACLPerSource).filter_by(acl=acl, fingerprint=fpr):
296 self.log.log(['dm-remove', fpr.fingerprint, 'source={0}'.format(entry.source)])
297 count += 1
298 session.delete(entry)
300 self.result.append('Removed: {0}.\n{1} acl entries removed.'.format(fpr.fingerprint, count))
302 session.commit()
304 def action_dm_migrate(self, fingerprint, section, session) -> None:
305 self._action_dm_admin_common(fingerprint, section, session)
306 cnf = Config()
307 acl_name = cnf.get('Command::DM::ACL', 'dm')
308 acl = session.query(ACL).filter_by(name=acl_name).one()
310 fpr_hash_from = section['From'].replace(' ', '')
311 fpr_from = session.query(Fingerprint).filter_by(fingerprint=fpr_hash_from).first()
312 if fpr_from is None:
313 self.result.append('Unknown fingerprint (From): {0}\nNo action taken.'.format(fpr_hash_from))
314 return
316 fpr_hash_to = section['To'].replace(' ', '')
317 fpr_to = session.query(Fingerprint).filter_by(fingerprint=fpr_hash_to).first()
318 if fpr_to is None:
319 self.result.append('Unknown fingerprint (To): {0}\nNo action taken.'.format(fpr_hash_to))
320 return
321 if fpr_to.keyring is None or fpr_to.keyring.keyring_name not in cnf.value_list('Command::DM::Keyrings'):
322 self.result.append('Key (To) {0} is not in DM keyring.\nNo action taken.'.format(fpr_to.fingerprint))
323 return
325 self.log.log(['dm-migrate', 'from={0}'.format(fpr_hash_from), 'to={0}'.format(fpr_hash_to)])
327 sources = []
328 for entry in session.query(ACLPerSource).filter_by(acl=acl, fingerprint=fpr_from):
329 self.log.log(['dm-migrate', 'from={0}'.format(fpr_hash_from), 'to={0}'.format(fpr_hash_to), 'source={0}'.format(entry.source)])
330 entry.fingerprint = fpr_to
331 sources.append(entry.source)
333 self.result.append('Migrated {0} to {1}.\n{2} acl entries changed: {3}'.format(fpr_hash_from, fpr_hash_to, len(sources), ", ".join(sources)))
335 session.commit()
337 def action_break_the_archive(self, fingerprint, section, session) -> None:
338 name = 'Dave'
339 uid = fingerprint.uid
340 if uid is not None and uid.name is not None:
341 name = uid.name.split()[0]
343 self.result.append("DAK9000: I'm sorry, {0}. I'm afraid I can't do that.".format(name))
345 def _sourcename_from_dbchanges(self, changes: DBChange) -> str:
346 source = changes.source
347 # in case the Source contains spaces, e.g. in binNMU .changes
348 source = source.split(' ')[0]
350 return source
352 def _process_upload_add_command_file(self, upload: PolicyQueueUpload, command) -> None:
353 source = self._sourcename_from_dbchanges(upload.changes)
354 filename = f"{command}.{source}_{upload.changes.version}"
355 content = "OK" if command == "ACCEPT" else "NOTOK"
357 with open(os.path.join(upload.policy_queue.path, "COMMENTS", filename), "x") as f:
358 f.write(content + "\n")
360 def _action_process_upload_common(self, fingerprint, section, session) -> None:
361 cnf = Config()
363 if 'Command::ProcessUpload::ACL' not in cnf: 363 ↛ 364line 363 didn't jump to line 364, because the condition on line 363 was never true
364 raise CommandError('Process Upload command is not configured for this archive.')
366 def action_process_upload(self, fingerprint, section, session) -> None:
367 self._action_process_upload_common(fingerprint, section, session)
369 cnf = Config()
370 acl_name = cnf.get('Command::ProcessUpload::ACL', 'process-upload')
371 acl = session.query(ACL).filter_by(name=acl_name).one()
373 source = section['Source'].replace(' ', '')
374 version = section['Version'].replace(' ', '')
375 command = section['Command'].replace(' ', '')
377 if command not in ('ACCEPT', 'REJECT'): 377 ↛ 378line 377 didn't jump to line 378, because the condition on line 377 was never true
378 raise CommandError('Invalid ProcessUpload command: {0}'.format(command))
380 dbsource = session.query(DBSource).filter_by(source=source, version=version)
381 uploads = session.query(PolicyQueueUpload).join(PolicyQueueUpload.changes) \
382 .filter_by(version=version) \
383 .all()
384 # we don't filter_by(source=source) because a source in a DBChange can
385 # contain more than the source, e.g. 'source (version)' for binNMUs
386 uploads = [upload for upload in uploads if self._sourcename_from_dbchanges(upload.changes) == source]
387 if not uploads: 387 ↛ 388line 387 didn't jump to line 388, because the condition on line 387 was never true
388 raise CommandError('Could not find upload for {0} {1}'.format(source, version))
390 upload = uploads[0]
392 # we consider all uploads except those for NEW, and take into account the
393 # target suite when checking for permissions
394 if upload.policy_queue.queue_name == 'new': 394 ↛ 395line 394 didn't jump to line 395, because the condition on line 394 was never true
395 raise CommandError('Processing uploads from NEW not allowed ({0} {1})'.format(source, version))
397 suite = upload.target_suite
399 self.log.log(['process-upload', fingerprint.fingerprint, source, version, upload.policy_queue.queue_name, suite.suite_name])
401 allowed = False
402 for entry in session.query(ACLPerSource).filter_by(acl=acl,
403 fingerprint=fingerprint,
404 source=source):
405 allowed = True
407 if not allowed:
408 for entry in session.query(ACLPerSuite).filter_by(acl=acl, 408 ↛ 411line 408 didn't jump to line 411, because the loop on line 408 never started
409 fingerprint=fingerprint,
410 suite=suite):
411 allowed = True
413 self.log.log(['process-upload', fingerprint.fingerprint, source, version, upload.policy_queue.queue_name, suite.suite_name, allowed])
415 if allowed:
416 self._process_upload_add_command_file(upload, command)
418 self.result.append('ProcessUpload: processed fp {0}: {1}_{2}/{3}'.format(fingerprint.fingerprint, source, version, suite.codename))