Coverage for daklib/command.py: 65%

267 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2026-08-03 16:46 +0000

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 

25from typing import TYPE_CHECKING, cast 

26 

27import apt_pkg 

28from sqlalchemy import delete, select 

29from sqlalchemy.engine import CursorResult 

30 

31from daklib.config import Config 

32from daklib.dak_exceptions import ParseMaintError 

33from daklib.dbconn import ( 

34 ACL, 

35 ACLPerSource, 

36 ACLPerSuite, 

37 DBChange, 

38 DBConn, 

39 DBSource, 

40 Fingerprint, 

41 PolicyQueueUpload, 

42 SignatureHistory, 

43 get_active_keyring_files, 

44) 

45from daklib.gpg import SignedFile 

46from daklib.regexes import re_field_package 

47from daklib.textutils import fix_maintainer 

48from daklib.utils import TemplateSubst, gpg_get_key_addresses, send_mail 

49 

50if TYPE_CHECKING: 

51 from sqlalchemy.orm import Session 

52 

53 

54class CommandError(Exception): 

55 pass 

56 

57 

58class CommandFile: 

59 def __init__(self, filename: str, data: bytes, log=None): 

60 if log is None: 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true

61 from daklib.daklog import Logger 

62 

63 log = Logger() 

64 self.cc: list[str] = [] 

65 self.result: list[str] = [] 

66 self.log = log 

67 self.filename: str = filename 

68 self.data = data 

69 self.uploader: str | None = None 

70 

71 def _check_replay(self, signed_file: SignedFile, session: "Session"): 

72 """check for replays 

73 

74 .. note:: 

75 

76 Will commit changes to the database. 

77 

78 :param session: database session 

79 """ 

80 # Mark commands file as seen to prevent replays. 

81 signature_history = SignatureHistory.from_signed_file(signed_file) 

82 session.add(signature_history) 

83 session.commit() 

84 

85 def _quote_section(self, section: apt_pkg.TagSection) -> str: 

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

87 return "\n".join(lines) 

88 

89 def _evaluate_sections(self, sections: apt_pkg.TagFile, session: "Session"): 

90 session.rollback() 

91 try: 

92 while True: 

93 next(sections) 

94 section: apt_pkg.TagSection = sections.section # type: ignore[attr-defined] 

95 self.result.append(self._quote_section(section)) 

96 

97 action = section.get("Action", None) 

98 if action is None: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true

99 raise CommandError("Encountered section without Action field") 

100 

101 if action == "dm": 

102 self.action_dm(self.fingerprint, section, session) 

103 elif action == "dm-remove": 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true

104 self.action_dm_remove(self.fingerprint, section, session) 

105 elif action == "dm-migrate": 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true

106 self.action_dm_migrate(self.fingerprint, section, session) 

107 elif action == "break-the-archive": 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 self.action_break_the_archive(self.fingerprint, section, session) 

109 elif action == "process-upload": 109 ↛ 112line 109 didn't jump to line 112 because the condition on line 109 was always true

110 self.action_process_upload(self.fingerprint, section, session) 

111 else: 

112 raise CommandError("Unknown action: {0}".format(action)) 

113 

114 self.result.append("") 

115 except StopIteration: 

116 pass 

117 finally: 

118 session.rollback() 

119 

120 def _notify_uploader(self): 

121 cnf = Config() 

122 

123 bcc = "X-DAK: dak process-command" 

124 if "Dinstall::Bcc" in cnf: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

125 bcc = "{0}\nBcc: {1}".format(bcc, cnf["Dinstall::Bcc"]) 

126 

127 maint_to = "" 

128 addresses = gpg_get_key_addresses(self.fingerprint.fingerprint) 

129 if len(addresses) > 0: 129 ↛ 132line 129 didn't jump to line 132 because the condition on line 129 was always true

130 maint_to = addresses[0] 

131 

132 if self.uploader: 

133 try: 

134 maint_to = fix_maintainer(self.uploader)[1] 

135 except ParseMaintError: 

136 self.log.log("ignoring malformed uploader", self.filename) 

137 

138 cc = set() 

139 for address in self.cc: 

140 try: 

141 cc.add(fix_maintainer(address)[1]) 

142 except ParseMaintError: 

143 self.log.log("ignoring malformed cc", self.filename) 

144 

145 subst = { 

146 "__DAK_ADDRESS__": cnf["Dinstall::MyEmailAddress"], 

147 "__MAINTAINER_TO__": maint_to, 

148 "__CC__": ", ".join(cc), 

149 "__BCC__": bcc, 

150 "__RESULTS__": "\n".join(self.result), 

151 "__FILENAME__": self.filename, 

152 } 

153 

154 message = TemplateSubst( 

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

156 ) 

157 

158 send_mail(message) 

159 

160 def evaluate(self) -> bool: 

161 """evaluate commands file 

162 

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

164 :const:`False` otherwise 

165 """ 

166 result = True 

167 

168 session = DBConn().session() 

169 

170 keyring_files = get_active_keyring_files(session) 

171 

172 signed_file = SignedFile(self.data, keyring_files) 

173 if not signed_file.valid: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true

174 self.log.log(["invalid signature", self.filename]) 

175 return False 

176 

177 self.fingerprint = session.execute( 

178 select(Fingerprint).filter_by(fingerprint=signed_file.primary_fingerprint) 

179 ).scalar_one() 

180 if self.fingerprint.keyring is None: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true

181 self.log.log(["signed by key in unknown keyring", self.filename]) 

182 return False 

183 assert self.fingerprint.keyring.active 

184 

185 self.log.log( 

186 [ 

187 "processing", 

188 self.filename, 

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

190 ] 

191 ) 

192 

193 with tempfile.TemporaryFile() as fh: 

194 fh.write(signed_file.contents) 

195 fh.seek(0) 

196 sections = apt_pkg.TagFile(fh) 

197 

198 try: 

199 next(sections) 

200 section: apt_pkg.TagSection = sections.section # type: ignore[attr-defined] 

201 if "Uploader" in section: 

202 self.uploader = section["Uploader"] 

203 if "Cc" in section: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true

204 self.cc.append(section["Cc"]) 

205 # TODO: Verify first section has valid Archive field 

206 if "Archive" not in section: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 raise CommandError("No Archive field in first section.") 

208 

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

210 self._check_replay(signed_file, session) 

211 

212 self._evaluate_sections(sections, session) 

213 self.result.append("") 

214 except Exception as e: 

215 self.log.log(["ERROR", e]) 

216 self.result.append( 

217 "There was an error processing this section. No changes were committed.\nDetails:\n{0}".format( 

218 e 

219 ) 

220 ) 

221 result = False 

222 

223 self._notify_uploader() 

224 

225 session.close() 

226 

227 return result 

228 

229 def _split_packages(self, value: str) -> list[str]: 

230 names = value.split() 

231 for name in names: 

232 if not re_field_package.match(name): 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true

233 raise CommandError('Invalid package name "{0}"'.format(name)) 

234 return names 

235 

236 def action_dm( 

237 self, fingerprint: Fingerprint, section: apt_pkg.TagSection, session: "Session" 

238 ) -> None: 

239 cnf = Config() 

240 

241 if ( 241 ↛ 246line 241 didn't jump to line 246

242 "Command::DM::AdminKeyrings" not in cnf 

243 or "Command::DM::ACL" not in cnf 

244 or "Command::DM::Keyrings" not in cnf 

245 ): 

246 raise CommandError("DM command is not configured for this archive.") 

247 

248 allowed_keyrings = cnf.value_list("Command::DM::AdminKeyrings") 

249 if ( 249 ↛ 253line 249 didn't jump to line 253

250 fingerprint.keyring is None 

251 or fingerprint.keyring.keyring_name not in allowed_keyrings 

252 ): 

253 raise CommandError( 

254 "Key {0} is not allowed to set DM".format(fingerprint.fingerprint) 

255 ) 

256 

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

258 acl = session.execute(select(ACL).filter_by(name=acl_name)).scalar_one() 

259 

260 fpr_hash = section["Fingerprint"].replace(" ", "") 

261 fpr = session.scalars( 

262 select(Fingerprint).filter_by(fingerprint=fpr_hash).limit(1) 

263 ).first() 

264 if fpr is None: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true

265 raise CommandError("Unknown fingerprint {0}".format(fpr_hash)) 

266 if fpr.keyring is None or fpr.keyring.keyring_name not in cnf.value_list( 

267 "Command::DM::Keyrings" 

268 ): 

269 raise CommandError("Key {0} is not in DM keyring.".format(fpr.fingerprint)) 

270 addresses = gpg_get_key_addresses(fpr.fingerprint) 

271 if len(addresses) > 0: 271 ↛ 274line 271 didn't jump to line 274 because the condition on line 271 was always true

272 self.cc.append(addresses[0]) 

273 

274 self.log.log(["dm", "fingerprint", fpr.fingerprint]) 

275 self.result.append("Fingerprint: {0}".format(fpr.fingerprint)) 

276 if len(addresses) > 0: 276 ↛ 280line 276 didn't jump to line 280 because the condition on line 276 was always true

277 self.log.log(["dm", "uid", addresses[0]]) 

278 self.result.append("Uid: {0}".format(addresses[0])) 

279 

280 for source in self._split_packages(section.get("Allow", "")): 

281 # Check for existance of source package to catch typos 

282 source_query = select(DBSource).filter_by(source=source).limit(1) 

283 if session.scalars(source_query).first() is None: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 raise CommandError( 

285 "Tried to grant permissions for unknown source package: {0}".format( 

286 source 

287 ) 

288 ) 

289 

290 if ( 290 ↛ 308line 290 didn't jump to line 308

291 session.scalars( 

292 select(ACLPerSource) 

293 .filter_by(acl=acl, fingerprint=fpr, source=source) 

294 .limit(1) 

295 ).first() 

296 is None 

297 ): 

298 aps = ACLPerSource() 

299 aps.acl = acl 

300 aps.fingerprint = fpr 

301 aps.source = source 

302 aps.created_by = fingerprint 

303 aps.reason = section.get("Reason") 

304 session.add(aps) 

305 self.log.log(["dm", "allow", fpr.fingerprint, source]) 

306 self.result.append("Allowed: {0}".format(source)) 

307 else: 

308 self.result.append("Already-Allowed: {0}".format(source)) 

309 

310 session.flush() 

311 

312 for source in self._split_packages(section.get("Deny", "")): 

313 count = cast( 

314 CursorResult, 

315 session.execute( 

316 delete(ACLPerSource).filter_by( 

317 acl=acl, fingerprint=fpr, source=source 

318 ) 

319 ), 

320 ).rowcount 

321 if count == 0: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true

322 raise CommandError( 

323 "Tried to remove upload permissions for package {0}, " 

324 "but no upload permissions were granted before.".format(source) 

325 ) 

326 

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

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

329 

330 session.commit() 

331 

332 def _action_dm_admin_common( 

333 self, fingerprint: Fingerprint, section: apt_pkg.TagSection, session: "Session" 

334 ) -> None: 

335 cnf = Config() 

336 

337 if ( 

338 "Command::DM-Admin::AdminFingerprints" not in cnf 

339 or "Command::DM::ACL" not in cnf 

340 ): 

341 raise CommandError("DM admin command is not configured for this archive.") 

342 

343 allowed_fingerprints = cnf.value_list("Command::DM-Admin::AdminFingerprints") 

344 if fingerprint.fingerprint not in allowed_fingerprints: 

345 raise CommandError( 

346 "Key {0} is not allowed to admin DM".format(fingerprint.fingerprint) 

347 ) 

348 

349 def action_dm_remove( 

350 self, fingerprint: Fingerprint, section: apt_pkg.TagSection, session: "Session" 

351 ) -> None: 

352 self._action_dm_admin_common(fingerprint, section, session) 

353 

354 cnf = Config() 

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

356 acl = session.execute(select(ACL).filter_by(name=acl_name)).scalar_one() 

357 

358 fpr_hash = section["Fingerprint"].replace(" ", "") 

359 fpr = session.scalars( 

360 select(Fingerprint).filter_by(fingerprint=fpr_hash).limit(1) 

361 ).first() 

362 if fpr is None: 

363 self.result.append( 

364 "Unknown fingerprint: {0}\nNo action taken.".format(fpr_hash) 

365 ) 

366 return 

367 

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

369 

370 count = 0 

371 for entry in session.scalars( 

372 select(ACLPerSource).filter_by(acl=acl, fingerprint=fpr) 

373 ): 

374 self.log.log( 

375 ["dm-remove", fpr.fingerprint, "source={0}".format(entry.source)] 

376 ) 

377 count += 1 

378 session.delete(entry) 

379 

380 self.result.append( 

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

382 ) 

383 

384 session.commit() 

385 

386 def action_dm_migrate( 

387 self, fingerprint: Fingerprint, section: apt_pkg.TagSection, session: "Session" 

388 ) -> None: 

389 self._action_dm_admin_common(fingerprint, section, session) 

390 cnf = Config() 

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

392 acl = session.execute(select(ACL).filter_by(name=acl_name)).scalar_one() 

393 

394 fpr_hash_from = section["From"].replace(" ", "") 

395 fpr_from = session.scalars( 

396 select(Fingerprint).filter_by(fingerprint=fpr_hash_from).limit(1) 

397 ).first() 

398 if fpr_from is None: 

399 self.result.append( 

400 "Unknown fingerprint (From): {0}\nNo action taken.".format( 

401 fpr_hash_from 

402 ) 

403 ) 

404 return 

405 

406 fpr_hash_to = section["To"].replace(" ", "") 

407 fpr_to = session.scalars( 

408 select(Fingerprint).filter_by(fingerprint=fpr_hash_to).limit(1) 

409 ).first() 

410 if fpr_to is None: 

411 self.result.append( 

412 "Unknown fingerprint (To): {0}\nNo action taken.".format(fpr_hash_to) 

413 ) 

414 return 

415 if fpr_to.keyring is None or fpr_to.keyring.keyring_name not in cnf.value_list( 

416 "Command::DM::Keyrings" 

417 ): 

418 self.result.append( 

419 "Key (To) {0} is not in DM keyring.\nNo action taken.".format( 

420 fpr_to.fingerprint 

421 ) 

422 ) 

423 return 

424 

425 self.log.log( 

426 [ 

427 "dm-migrate", 

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

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

430 ] 

431 ) 

432 

433 sources = [] 

434 for entry in session.scalars( 

435 select(ACLPerSource).filter_by(acl=acl, fingerprint=fpr_from) 

436 ): 

437 self.log.log( 

438 [ 

439 "dm-migrate", 

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

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

442 "source={0}".format(entry.source), 

443 ] 

444 ) 

445 entry.fingerprint = fpr_to 

446 sources.append(entry.source) 

447 

448 self.result.append( 

449 "Migrated {0} to {1}.\n{2} acl entries changed: {3}".format( 

450 fpr_hash_from, fpr_hash_to, len(sources), ", ".join(sources) 

451 ) 

452 ) 

453 

454 session.commit() 

455 

456 def action_break_the_archive( 

457 self, fingerprint: Fingerprint, section: apt_pkg.TagSection, session: "Session" 

458 ) -> None: 

459 name = "Dave" 

460 uid = fingerprint.uid 

461 if uid is not None and uid.name is not None: 

462 name = uid.name.split()[0] 

463 

464 self.result.append( 

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

466 ) 

467 

468 def _sourcename_from_dbchanges(self, changes: DBChange) -> str: 

469 source = changes.source 

470 # in case the Source contains spaces, e.g. in binNMU .changes 

471 source = source.split(" ")[0] 

472 

473 return source 

474 

475 def _process_upload_add_command_file( 

476 self, upload: PolicyQueueUpload, command: str 

477 ) -> None: 

478 source = self._sourcename_from_dbchanges(upload.changes) 

479 filename = f"{command}.{source}_{upload.changes.version}" 

480 content = "OK" if command == "ACCEPT" else "NOTOK" 

481 

482 with open( 

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

484 ) as f: 

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

486 

487 def _action_process_upload_common( 

488 self, fingerprint: Fingerprint, section: apt_pkg.TagSection, session: "Session" 

489 ) -> None: 

490 cnf = Config() 

491 

492 if "Command::ProcessUpload::ACL" not in cnf: 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true

493 raise CommandError( 

494 "Process Upload command is not configured for this archive." 

495 ) 

496 

497 def action_process_upload( 

498 self, fingerprint: Fingerprint, section: apt_pkg.TagSection, session: "Session" 

499 ) -> None: 

500 self._action_process_upload_common(fingerprint, section, session) 

501 

502 cnf = Config() 

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

504 acl = session.execute(select(ACL).filter_by(name=acl_name)).scalar_one() 

505 

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

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

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

509 

510 if command not in ("ACCEPT", "REJECT"): 510 ↛ 511line 510 didn't jump to line 511 because the condition on line 510 was never true

511 raise CommandError("Invalid ProcessUpload command: {0}".format(command)) 

512 

513 uploads = session.scalars( 

514 select(PolicyQueueUpload) 

515 .join(PolicyQueueUpload.changes) 

516 .where(DBChange.version == version) 

517 ).all() 

518 # we don't filter_by(source=source) because a source in a DBChange can 

519 # contain more than the source, e.g. 'source (version)' for binNMUs 

520 uploads = [ 

521 upload 

522 for upload in uploads 

523 if self._sourcename_from_dbchanges(upload.changes) == source 

524 ] 

525 if not uploads: 525 ↛ 526line 525 didn't jump to line 526 because the condition on line 525 was never true

526 raise CommandError( 

527 "Could not find upload for {0} {1}".format(source, version) 

528 ) 

529 

530 upload = uploads[0] 

531 

532 # we consider all uploads except those for NEW, and take into account the 

533 # target suite when checking for permissions 

534 if upload.policy_queue.queue_name == "new": 534 ↛ 535line 534 didn't jump to line 535 because the condition on line 534 was never true

535 raise CommandError( 

536 "Processing uploads from NEW not allowed ({0} {1})".format( 

537 source, version 

538 ) 

539 ) 

540 

541 suite = upload.target_suite 

542 

543 self.log.log( 

544 [ 

545 "process-upload", 

546 fingerprint.fingerprint, 

547 source, 

548 version, 

549 upload.policy_queue.queue_name, 

550 suite.suite_name, 

551 ] 

552 ) 

553 

554 per_source_query = select(ACLPerSource).filter_by( 

555 acl=acl, fingerprint=fingerprint, source=source 

556 ) 

557 per_suite_query = select(ACLPerSuite).filter_by( 

558 acl=acl, fingerprint=fingerprint, suite=suite 

559 ) 

560 allowed = bool(session.scalar(select(per_source_query.exists()))) or bool( 

561 session.scalar(select(per_suite_query.exists())) 

562 ) 

563 

564 self.log.log( 

565 [ 

566 "process-upload", 

567 fingerprint.fingerprint, 

568 source, 

569 version, 

570 upload.policy_queue.queue_name, 

571 suite.suite_name, 

572 allowed, 

573 ] 

574 ) 

575 

576 if allowed: 

577 self._process_upload_add_command_file(upload, command) 

578 

579 self.result.append( 

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

581 fingerprint.fingerprint, source, version, suite.codename 

582 ) 

583 )