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 apt_pkg 

24import os 

25import tempfile 

26 

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 

34 

35 

36class CommandError(Exception): 

37 pass 

38 

39 

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 

51 

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

53 """check for replays 

54 

55 .. note:: 

56 

57 Will commit changes to the database. 

58 

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

65 

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) 

71 

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

79 

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

83 

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

96 

97 self.result.append('') 

98 except StopIteration: 

99 pass 

100 finally: 

101 session.rollback() 

102 

103 def _notify_uploader(self): 

104 cnf = Config() 

105 

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

109 

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] 

114 

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) 

120 

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) 

127 

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 } 

136 

137 message = TemplateSubst(subst, os.path.join(cnf['Dir::Templates'], 'process-command.processed')) 

138 

139 send_mail(message) 

140 

141 def evaluate(self) -> bool: 

142 """evaluate commands file 

143 

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

145 :const:`False` otherwise 

146 """ 

147 result = True 

148 

149 session = DBConn().session() 

150 

151 keyrings = session.query(Keyring).filter_by(active=True).order_by(Keyring.priority) 

152 keyring_files = [k.keyring_name for k in keyrings] 

153 

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 

158 

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 

164 

165 self.log.log(['processing', self.filename, 'signed-by={0}'.format(self.fingerprint.fingerprint)]) 

166 

167 with tempfile.TemporaryFile() as fh: 

168 fh.write(signed_file.contents) 

169 fh.seek(0) 

170 sections = apt_pkg.TagFile(fh) 

171 

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

182 

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

184 self._check_replay(signed_file, session) 

185 

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 

192 

193 self._notify_uploader() 

194 

195 session.close() 

196 

197 return result 

198 

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 

205 

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

207 cnf = Config() 

208 

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

213 

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

217 

218 acl_name = cnf.get('Command::DM::ACL', 'dm') 

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

220 

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

230 

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

236 

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

241 

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

254 

255 session.flush() 

256 

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

262 

263 self.log.log(['dm', 'deny', fpr.fingerprint, source]) 

264 self.result.append('Denied: {0}'.format(source)) 

265 

266 session.commit() 

267 

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

269 cnf = Config() 

270 

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

274 

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

278 

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

280 self._action_dm_admin_common(fingerprint, section, session) 

281 

282 cnf = Config() 

283 acl_name = cnf.get('Command::DM::ACL', 'dm') 

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

285 

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 

291 

292 self.log.log(['dm-remove', fpr.fingerprint]) 

293 

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) 

299 

300 self.result.append('Removed: {0}.\n{1} acl entries removed.'.format(fpr.fingerprint, count)) 

301 

302 session.commit() 

303 

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

309 

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 

315 

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 

324 

325 self.log.log(['dm-migrate', 'from={0}'.format(fpr_hash_from), 'to={0}'.format(fpr_hash_to)]) 

326 

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) 

332 

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

334 

335 session.commit() 

336 

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] 

342 

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

344 

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] 

349 

350 return source 

351 

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" 

356 

357 with open(os.path.join(upload.policy_queue.path, "COMMENTS", filename), "x") as f: 

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

359 

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

361 cnf = Config() 

362 

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

365 

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

367 self._action_process_upload_common(fingerprint, section, session) 

368 

369 cnf = Config() 

370 acl_name = cnf.get('Command::ProcessUpload::ACL', 'process-upload') 

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

372 

373 source = section['Source'].replace(' ', '') 

374 version = section['Version'].replace(' ', '') 

375 command = section['Command'].replace(' ', '') 

376 

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

379 

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

389 

390 upload = uploads[0] 

391 

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

396 

397 suite = upload.target_suite 

398 

399 self.log.log(['process-upload', fingerprint.fingerprint, source, version, upload.policy_queue.queue_name, suite.suite_name]) 

400 

401 allowed = False 

402 for entry in session.query(ACLPerSource).filter_by(acl=acl, 

403 fingerprint=fingerprint, 

404 source=source): 

405 allowed = True 

406 

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 

412 

413 self.log.log(['process-upload', fingerprint.fingerprint, source, version, upload.policy_queue.queue_name, suite.suite_name, allowed]) 

414 

415 if allowed: 

416 self._process_upload_add_command_file(upload, command) 

417 

418 self.result.append('ProcessUpload: processed fp {0}: {1}_{2}/{3}'.format(fingerprint.fingerprint, source, version, suite.codename))