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 os
24import tempfile
26import apt_pkg
28from daklib.config import Config
29from daklib.dak_exceptions import ParseMaintError
30from daklib.dbconn import (
31 ACL,
32 ACLPerSource,
33 ACLPerSuite,
34 DBChange,
35 DBConn,
36 DBSource,
37 Fingerprint,
38 Keyring,
39 PolicyQueueUpload,
40 SignatureHistory,
41)
42from daklib.gpg import SignedFile
43from daklib.regexes import re_field_package
44from daklib.textutils import fix_maintainer
45from daklib.utils import TemplateSubst, gpg_get_key_addresses, send_mail
48class CommandError(Exception):
49 pass
52class CommandFile:
53 def __init__(self, filename: str, data: bytes, log=None):
54 if log is None: 54 ↛ 55line 54 didn't jump to line 55, because the condition on line 54 was never true
55 from daklib.daklog import Logger
57 log = Logger()
58 self.cc: list[str] = []
59 self.result = []
60 self.log = log
61 self.filename: str = filename
62 self.data = data
63 self.uploader = None
65 def _check_replay(self, signed_file: SignedFile, session):
66 """check for replays
68 .. note::
70 Will commit changes to the database.
72 :param session: database session
73 """
74 # Mark commands file as seen to prevent replays.
75 signature_history = SignatureHistory.from_signed_file(signed_file)
76 session.add(signature_history)
77 session.commit()
79 def _quote_section(self, section) -> str:
80 lines = [f"> {line}" for line in str(section).splitlines()]
81 return "\n".join(lines)
83 def _evaluate_sections(self, sections, session):
84 session.rollback()
85 try:
86 while True:
87 next(sections)
88 section = sections.section
89 self.result.append(self._quote_section(section))
91 action = section.get("Action", None)
92 if action is None: 92 ↛ 93line 92 didn't jump to line 93, because the condition on line 92 was never true
93 raise CommandError("Encountered section without Action field")
95 if action == "dm":
96 self.action_dm(self.fingerprint, section, session)
97 elif action == "dm-remove": 97 ↛ 98line 97 didn't jump to line 98, because the condition on line 97 was never true
98 self.action_dm_remove(self.fingerprint, section, session)
99 elif action == "dm-migrate": 99 ↛ 100line 99 didn't jump to line 100, because the condition on line 99 was never true
100 self.action_dm_migrate(self.fingerprint, section, session)
101 elif action == "break-the-archive": 101 ↛ 102line 101 didn't jump to line 102, because the condition on line 101 was never true
102 self.action_break_the_archive(self.fingerprint, section, session)
103 elif action == "process-upload": 103 ↛ 106line 103 didn't jump to line 106, because the condition on line 103 was never false
104 self.action_process_upload(self.fingerprint, section, session)
105 else:
106 raise CommandError("Unknown action: {0}".format(action))
108 self.result.append("")
109 except StopIteration:
110 pass
111 finally:
112 session.rollback()
114 def _notify_uploader(self):
115 cnf = Config()
117 bcc = "X-DAK: dak process-command"
118 if "Dinstall::Bcc" in cnf: 118 ↛ 119line 118 didn't jump to line 119, because the condition on line 118 was never true
119 bcc = "{0}\nBcc: {1}".format(bcc, cnf["Dinstall::Bcc"])
121 maint_to = None
122 addresses = gpg_get_key_addresses(self.fingerprint.fingerprint)
123 if len(addresses) > 0: 123 ↛ 126line 123 didn't jump to line 126, because the condition on line 123 was never false
124 maint_to = addresses[0]
126 if self.uploader:
127 try:
128 maint_to = fix_maintainer(self.uploader)[1]
129 except ParseMaintError:
130 self.log.log("ignoring malformed uploader", self.filename)
132 cc = set()
133 for address in self.cc:
134 try:
135 cc.add(fix_maintainer(address)[1])
136 except ParseMaintError:
137 self.log.log("ignoring malformed cc", self.filename)
139 subst = {
140 "__DAK_ADDRESS__": cnf["Dinstall::MyEmailAddress"],
141 "__MAINTAINER_TO__": maint_to,
142 "__CC__": ", ".join(cc),
143 "__BCC__": bcc,
144 "__RESULTS__": "\n".join(self.result),
145 "__FILENAME__": self.filename,
146 }
148 message = TemplateSubst(
149 subst, os.path.join(cnf["Dir::Templates"], "process-command.processed")
150 )
152 send_mail(message)
154 def evaluate(self) -> bool:
155 """evaluate commands file
157 :return: :const:`True` if the file was processed sucessfully,
158 :const:`False` otherwise
159 """
160 result = True
162 session = DBConn().session()
164 keyrings = (
165 session.query(Keyring).filter_by(active=True).order_by(Keyring.priority)
166 )
167 keyring_files = [k.keyring_name for k in keyrings]
169 signed_file = SignedFile(self.data, keyring_files)
170 if not signed_file.valid: 170 ↛ 171line 170 didn't jump to line 171, because the condition on line 170 was never true
171 self.log.log(["invalid signature", self.filename])
172 return False
174 self.fingerprint = (
175 session.query(Fingerprint)
176 .filter_by(fingerprint=signed_file.primary_fingerprint)
177 .one()
178 )
179 if self.fingerprint.keyring is None: 179 ↛ 180line 179 didn't jump to line 180, because the condition on line 179 was never true
180 self.log.log(["signed by key in unknown keyring", self.filename])
181 return False
182 assert self.fingerprint.keyring.active
184 self.log.log(
185 [
186 "processing",
187 self.filename,
188 "signed-by={0}".format(self.fingerprint.fingerprint),
189 ]
190 )
192 with tempfile.TemporaryFile() as fh:
193 fh.write(signed_file.contents)
194 fh.seek(0)
195 sections = apt_pkg.TagFile(fh)
197 try:
198 next(sections)
199 section = sections.section
200 if "Uploader" in section:
201 self.uploader = section["Uploader"]
202 if "Cc" in section: 202 ↛ 203line 202 didn't jump to line 203, because the condition on line 202 was never true
203 self.cc.append(section["Cc"])
204 # TODO: Verify first section has valid Archive field
205 if "Archive" not in section: 205 ↛ 206line 205 didn't jump to line 206, because the condition on line 205 was never true
206 raise CommandError("No Archive field in first section.")
208 # TODO: send mail when we detected a replay.
209 self._check_replay(signed_file, session)
211 self._evaluate_sections(sections, session)
212 self.result.append("")
213 except Exception as e:
214 self.log.log(["ERROR", e])
215 self.result.append(
216 "There was an error processing this section. No changes were committed.\nDetails:\n{0}".format(
217 e
218 )
219 )
220 result = False
222 self._notify_uploader()
224 session.close()
226 return result
228 def _split_packages(self, value: str) -> list[str]:
229 names = value.split()
230 for name in names:
231 if not re_field_package.match(name): 231 ↛ 232line 231 didn't jump to line 232, because the condition on line 231 was never true
232 raise CommandError('Invalid package name "{0}"'.format(name))
233 return names
235 def action_dm(self, fingerprint, section, session) -> None:
236 cnf = Config()
238 if ( 238 ↛ 243line 238 didn't jump to line 243
239 "Command::DM::AdminKeyrings" not in cnf
240 or "Command::DM::ACL" not in cnf
241 or "Command::DM::Keyrings" not in cnf
242 ):
243 raise CommandError("DM command is not configured for this archive.")
245 allowed_keyrings = cnf.value_list("Command::DM::AdminKeyrings")
246 if fingerprint.keyring.keyring_name not in allowed_keyrings: 246 ↛ 247line 246 didn't jump to line 247, because the condition on line 246 was never true
247 raise CommandError(
248 "Key {0} is not allowed to set DM".format(fingerprint.fingerprint)
249 )
251 acl_name = cnf.get("Command::DM::ACL", "dm")
252 acl = session.query(ACL).filter_by(name=acl_name).one()
254 fpr_hash = section["Fingerprint"].replace(" ", "")
255 fpr = session.query(Fingerprint).filter_by(fingerprint=fpr_hash).first()
256 if fpr is None: 256 ↛ 257line 256 didn't jump to line 257, because the condition on line 256 was never true
257 raise CommandError("Unknown fingerprint {0}".format(fpr_hash))
258 if fpr.keyring is None or fpr.keyring.keyring_name not in cnf.value_list(
259 "Command::DM::Keyrings"
260 ):
261 raise CommandError("Key {0} is not in DM keyring.".format(fpr.fingerprint))
262 addresses = gpg_get_key_addresses(fpr.fingerprint)
263 if len(addresses) > 0: 263 ↛ 266line 263 didn't jump to line 266, because the condition on line 263 was never false
264 self.cc.append(addresses[0])
266 self.log.log(["dm", "fingerprint", fpr.fingerprint])
267 self.result.append("Fingerprint: {0}".format(fpr.fingerprint))
268 if len(addresses) > 0: 268 ↛ 272line 268 didn't jump to line 272, because the condition on line 268 was never false
269 self.log.log(["dm", "uid", addresses[0]])
270 self.result.append("Uid: {0}".format(addresses[0]))
272 for source in self._split_packages(section.get("Allow", "")):
273 # Check for existance of source package to catch typos
274 if session.query(DBSource).filter_by(source=source).first() is None: 274 ↛ 275line 274 didn't jump to line 275, because the condition on line 274 was never true
275 raise CommandError(
276 "Tried to grant permissions for unknown source package: {0}".format(
277 source
278 )
279 )
281 if ( 281 ↛ 297line 281 didn't jump to line 297
282 session.query(ACLPerSource)
283 .filter_by(acl=acl, fingerprint=fpr, source=source)
284 .first()
285 is None
286 ):
287 aps = ACLPerSource()
288 aps.acl = acl
289 aps.fingerprint = fpr
290 aps.source = source
291 aps.created_by = fingerprint
292 aps.reason = section.get("Reason")
293 session.add(aps)
294 self.log.log(["dm", "allow", fpr.fingerprint, source])
295 self.result.append("Allowed: {0}".format(source))
296 else:
297 self.result.append("Already-Allowed: {0}".format(source))
299 session.flush()
301 for source in self._split_packages(section.get("Deny", "")):
302 count = (
303 session.query(ACLPerSource)
304 .filter_by(acl=acl, fingerprint=fpr, source=source)
305 .delete()
306 )
307 if count == 0: 307 ↛ 308line 307 didn't jump to line 308, because the condition on line 307 was never true
308 raise CommandError(
309 "Tried to remove upload permissions for package {0}, "
310 "but no upload permissions were granted before.".format(source)
311 )
313 self.log.log(["dm", "deny", fpr.fingerprint, source])
314 self.result.append("Denied: {0}".format(source))
316 session.commit()
318 def _action_dm_admin_common(self, fingerprint, section, session) -> None:
319 cnf = Config()
321 if (
322 "Command::DM-Admin::AdminFingerprints" not in cnf
323 or "Command::DM::ACL" not in cnf
324 ):
325 raise CommandError("DM admin command is not configured for this archive.")
327 allowed_fingerprints = cnf.value_list("Command::DM-Admin::AdminFingerprints")
328 if fingerprint.fingerprint not in allowed_fingerprints:
329 raise CommandError(
330 "Key {0} is not allowed to admin DM".format(fingerprint.fingerprint)
331 )
333 def action_dm_remove(self, fingerprint, section, session) -> None:
334 self._action_dm_admin_common(fingerprint, section, session)
336 cnf = Config()
337 acl_name = cnf.get("Command::DM::ACL", "dm")
338 acl = session.query(ACL).filter_by(name=acl_name).one()
340 fpr_hash = section["Fingerprint"].replace(" ", "")
341 fpr = session.query(Fingerprint).filter_by(fingerprint=fpr_hash).first()
342 if fpr is None:
343 self.result.append(
344 "Unknown fingerprint: {0}\nNo action taken.".format(fpr_hash)
345 )
346 return
348 self.log.log(["dm-remove", fpr.fingerprint])
350 count = 0
351 for entry in session.query(ACLPerSource).filter_by(acl=acl, fingerprint=fpr):
352 self.log.log(
353 ["dm-remove", fpr.fingerprint, "source={0}".format(entry.source)]
354 )
355 count += 1
356 session.delete(entry)
358 self.result.append(
359 "Removed: {0}.\n{1} acl entries removed.".format(fpr.fingerprint, count)
360 )
362 session.commit()
364 def action_dm_migrate(self, fingerprint, section, session) -> None:
365 self._action_dm_admin_common(fingerprint, section, session)
366 cnf = Config()
367 acl_name = cnf.get("Command::DM::ACL", "dm")
368 acl = session.query(ACL).filter_by(name=acl_name).one()
370 fpr_hash_from = section["From"].replace(" ", "")
371 fpr_from = (
372 session.query(Fingerprint).filter_by(fingerprint=fpr_hash_from).first()
373 )
374 if fpr_from is None:
375 self.result.append(
376 "Unknown fingerprint (From): {0}\nNo action taken.".format(
377 fpr_hash_from
378 )
379 )
380 return
382 fpr_hash_to = section["To"].replace(" ", "")
383 fpr_to = session.query(Fingerprint).filter_by(fingerprint=fpr_hash_to).first()
384 if fpr_to is None:
385 self.result.append(
386 "Unknown fingerprint (To): {0}\nNo action taken.".format(fpr_hash_to)
387 )
388 return
389 if fpr_to.keyring is None or fpr_to.keyring.keyring_name not in cnf.value_list(
390 "Command::DM::Keyrings"
391 ):
392 self.result.append(
393 "Key (To) {0} is not in DM keyring.\nNo action taken.".format(
394 fpr_to.fingerprint
395 )
396 )
397 return
399 self.log.log(
400 [
401 "dm-migrate",
402 "from={0}".format(fpr_hash_from),
403 "to={0}".format(fpr_hash_to),
404 ]
405 )
407 sources = []
408 for entry in session.query(ACLPerSource).filter_by(
409 acl=acl, fingerprint=fpr_from
410 ):
411 self.log.log(
412 [
413 "dm-migrate",
414 "from={0}".format(fpr_hash_from),
415 "to={0}".format(fpr_hash_to),
416 "source={0}".format(entry.source),
417 ]
418 )
419 entry.fingerprint = fpr_to
420 sources.append(entry.source)
422 self.result.append(
423 "Migrated {0} to {1}.\n{2} acl entries changed: {3}".format(
424 fpr_hash_from, fpr_hash_to, len(sources), ", ".join(sources)
425 )
426 )
428 session.commit()
430 def action_break_the_archive(self, fingerprint, section, session) -> None:
431 name = "Dave"
432 uid = fingerprint.uid
433 if uid is not None and uid.name is not None:
434 name = uid.name.split()[0]
436 self.result.append(
437 "DAK9000: I'm sorry, {0}. I'm afraid I can't do that.".format(name)
438 )
440 def _sourcename_from_dbchanges(self, changes: DBChange) -> str:
441 source = changes.source
442 # in case the Source contains spaces, e.g. in binNMU .changes
443 source = source.split(" ")[0]
445 return source
447 def _process_upload_add_command_file(
448 self, upload: PolicyQueueUpload, command
449 ) -> None:
450 source = self._sourcename_from_dbchanges(upload.changes)
451 filename = f"{command}.{source}_{upload.changes.version}"
452 content = "OK" if command == "ACCEPT" else "NOTOK"
454 with open(
455 os.path.join(upload.policy_queue.path, "COMMENTS", filename), "x"
456 ) as f:
457 f.write(content + "\n")
459 def _action_process_upload_common(self, fingerprint, section, session) -> None:
460 cnf = Config()
462 if "Command::ProcessUpload::ACL" not in cnf: 462 ↛ 463line 462 didn't jump to line 463, because the condition on line 462 was never true
463 raise CommandError(
464 "Process Upload command is not configured for this archive."
465 )
467 def action_process_upload(self, fingerprint, section, session) -> None:
468 self._action_process_upload_common(fingerprint, section, session)
470 cnf = Config()
471 acl_name = cnf.get("Command::ProcessUpload::ACL", "process-upload")
472 acl = session.query(ACL).filter_by(name=acl_name).one()
474 source = section["Source"].replace(" ", "")
475 version = section["Version"].replace(" ", "")
476 command = section["Command"].replace(" ", "")
478 if command not in ("ACCEPT", "REJECT"): 478 ↛ 479line 478 didn't jump to line 479, because the condition on line 478 was never true
479 raise CommandError("Invalid ProcessUpload command: {0}".format(command))
481 uploads = (
482 session.query(PolicyQueueUpload)
483 .join(PolicyQueueUpload.changes)
484 .filter_by(version=version)
485 .all()
486 )
487 # we don't filter_by(source=source) because a source in a DBChange can
488 # contain more than the source, e.g. 'source (version)' for binNMUs
489 uploads = [
490 upload
491 for upload in uploads
492 if self._sourcename_from_dbchanges(upload.changes) == source
493 ]
494 if not uploads: 494 ↛ 495line 494 didn't jump to line 495, because the condition on line 494 was never true
495 raise CommandError(
496 "Could not find upload for {0} {1}".format(source, version)
497 )
499 upload = uploads[0]
501 # we consider all uploads except those for NEW, and take into account the
502 # target suite when checking for permissions
503 if upload.policy_queue.queue_name == "new": 503 ↛ 504line 503 didn't jump to line 504, because the condition on line 503 was never true
504 raise CommandError(
505 "Processing uploads from NEW not allowed ({0} {1})".format(
506 source, version
507 )
508 )
510 suite = upload.target_suite
512 self.log.log(
513 [
514 "process-upload",
515 fingerprint.fingerprint,
516 source,
517 version,
518 upload.policy_queue.queue_name,
519 suite.suite_name,
520 ]
521 )
523 allowed = False
524 for entry in session.query(ACLPerSource).filter_by(
525 acl=acl, fingerprint=fingerprint, source=source
526 ):
527 allowed = True
529 if not allowed:
530 for entry in session.query(ACLPerSuite).filter_by( 530 ↛ 533line 530 didn't jump to line 533, because the loop on line 530 never started
531 acl=acl, fingerprint=fingerprint, suite=suite
532 ):
533 allowed = True
535 self.log.log(
536 [
537 "process-upload",
538 fingerprint.fingerprint,
539 source,
540 version,
541 upload.policy_queue.queue_name,
542 suite.suite_name,
543 allowed,
544 ]
545 )
547 if allowed:
548 self._process_upload_add_command_file(upload, command)
550 self.result.append(
551 "ProcessUpload: processed fp {0}: {1}_{2}/{3}".format(
552 fingerprint.fingerprint, source, version, suite.codename
553 )
554 )