1"""module to handle command files 

2 

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""" 

8 

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. 

22 

23import os 

24import tempfile 

25 

26import apt_pkg 

27 

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 

46 

47 

48class CommandError(Exception): 

49 pass 

50 

51 

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 

56 

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 

64 

65 def _check_replay(self, signed_file: SignedFile, session): 

66 """check for replays 

67 

68 .. note:: 

69 

70 Will commit changes to the database. 

71 

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() 

78 

79 def _quote_section(self, section) -> str: 

80 lines = [f"> {line}" for line in str(section).splitlines()] 

81 return "\n".join(lines) 

82 

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)) 

90 

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") 

94 

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)) 

107 

108 self.result.append("") 

109 except StopIteration: 

110 pass 

111 finally: 

112 session.rollback() 

113 

114 def _notify_uploader(self): 

115 cnf = Config() 

116 

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"]) 

120 

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] 

125 

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) 

131 

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) 

138 

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 } 

147 

148 message = TemplateSubst( 

149 subst, os.path.join(cnf["Dir::Templates"], "process-command.processed") 

150 ) 

151 

152 send_mail(message) 

153 

154 def evaluate(self) -> bool: 

155 """evaluate commands file 

156 

157 :return: :const:`True` if the file was processed sucessfully, 

158 :const:`False` otherwise 

159 """ 

160 result = True 

161 

162 session = DBConn().session() 

163 

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] 

168 

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 

173 

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 

183 

184 self.log.log( 

185 [ 

186 "processing", 

187 self.filename, 

188 "signed-by={0}".format(self.fingerprint.fingerprint), 

189 ] 

190 ) 

191 

192 with tempfile.TemporaryFile() as fh: 

193 fh.write(signed_file.contents) 

194 fh.seek(0) 

195 sections = apt_pkg.TagFile(fh) 

196 

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.") 

207 

208 # TODO: send mail when we detected a replay. 

209 self._check_replay(signed_file, session) 

210 

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 

221 

222 self._notify_uploader() 

223 

224 session.close() 

225 

226 return result 

227 

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 

234 

235 def action_dm(self, fingerprint, section, session) -> None: 

236 cnf = Config() 

237 

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.") 

244 

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 ) 

250 

251 acl_name = cnf.get("Command::DM::ACL", "dm") 

252 acl = session.query(ACL).filter_by(name=acl_name).one() 

253 

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]) 

265 

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])) 

271 

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 ) 

280 

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)) 

298 

299 session.flush() 

300 

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 ) 

312 

313 self.log.log(["dm", "deny", fpr.fingerprint, source]) 

314 self.result.append("Denied: {0}".format(source)) 

315 

316 session.commit() 

317 

318 def _action_dm_admin_common(self, fingerprint, section, session) -> None: 

319 cnf = Config() 

320 

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.") 

326 

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 ) 

332 

333 def action_dm_remove(self, fingerprint, section, session) -> None: 

334 self._action_dm_admin_common(fingerprint, section, session) 

335 

336 cnf = Config() 

337 acl_name = cnf.get("Command::DM::ACL", "dm") 

338 acl = session.query(ACL).filter_by(name=acl_name).one() 

339 

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 

347 

348 self.log.log(["dm-remove", fpr.fingerprint]) 

349 

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) 

357 

358 self.result.append( 

359 "Removed: {0}.\n{1} acl entries removed.".format(fpr.fingerprint, count) 

360 ) 

361 

362 session.commit() 

363 

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() 

369 

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 

381 

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 

398 

399 self.log.log( 

400 [ 

401 "dm-migrate", 

402 "from={0}".format(fpr_hash_from), 

403 "to={0}".format(fpr_hash_to), 

404 ] 

405 ) 

406 

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) 

421 

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 ) 

427 

428 session.commit() 

429 

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] 

435 

436 self.result.append( 

437 "DAK9000: I'm sorry, {0}. I'm afraid I can't do that.".format(name) 

438 ) 

439 

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] 

444 

445 return source 

446 

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" 

453 

454 with open( 

455 os.path.join(upload.policy_queue.path, "COMMENTS", filename), "x" 

456 ) as f: 

457 f.write(content + "\n") 

458 

459 def _action_process_upload_common(self, fingerprint, section, session) -> None: 

460 cnf = Config() 

461 

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 ) 

466 

467 def action_process_upload(self, fingerprint, section, session) -> None: 

468 self._action_process_upload_common(fingerprint, section, session) 

469 

470 cnf = Config() 

471 acl_name = cnf.get("Command::ProcessUpload::ACL", "process-upload") 

472 acl = session.query(ACL).filter_by(name=acl_name).one() 

473 

474 source = section["Source"].replace(" ", "") 

475 version = section["Version"].replace(" ", "") 

476 command = section["Command"].replace(" ", "") 

477 

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)) 

480 

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 ) 

498 

499 upload = uploads[0] 

500 

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 ) 

509 

510 suite = upload.target_suite 

511 

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 ) 

522 

523 allowed = False 

524 for entry in session.query(ACLPerSource).filter_by( 

525 acl=acl, fingerprint=fingerprint, source=source 

526 ): 

527 allowed = True 

528 

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 

534 

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 ) 

546 

547 if allowed: 

548 self._process_upload_add_command_file(upload, command) 

549 

550 self.result.append( 

551 "ProcessUpload: processed fp {0}: {1}_{2}/{3}".format( 

552 fingerprint.fingerprint, source, version, suite.codename 

553 ) 

554 )