Coverage for python/univention/config_registry/backend.py: 94%

291 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2026-09-11 14:11 +0000

1# 

2# main configuration registry classes 

3# 

4# SPDX-FileCopyrightText: 2004-2026 Univention GmbH 

5# SPDX-License-Identifier: AGPL-3.0-only 

6 

7"""Univention Configuration Registry backend for data storage.""" 

8 

9 

10import errno 

11import fcntl 

12import os 

13import re 

14import sys 

15import time 

16from collections.abc import ItemsView, Iterator, Mapping, MutableMapping 

17from enum import IntEnum 

18from stat import S_ISREG 

19from types import TracebackType 

20from typing import IO, Literal, NoReturn, Self, TypeVar, overload 

21 

22from univention.config_registry.handler import run_filter 

23 

24 

25_VT = TypeVar('_VT') 

26 

27__all__ = ['SCOPE', 'ConfigRegistry', 'StrictModeException', 'exception_occured'] 

28MYPY = False 

29INVALID_VALUE_CHARS = '\r\n' 

30 

31 

32class StrictModeException(Exception): 

33 """Attempt to store non-UTF-8 characters in strict UTF-8 mode.""" 

34 

35 

36def exception_occured(out: IO = sys.stderr) -> NoReturn: 

37 """ 

38 Print exception message and exit. 

39 

40 :param out: Output stream for message. 

41 """ 

42 print('E: your request could not be fulfilled', file=out) 

43 print('try `univention-config-registry --help` for more information', file=out) 

44 sys.exit(1) 

45 

46 

47SCOPE = ['default', 'normal', 'ldap', 'schedule', 'forced', 'custom'] 

48 

49 

50class Load(IntEnum): 

51 MANUAL = 0 

52 ONCE = 1 

53 ALWAYS = 2 

54 

55 

56if MYPY: # pragma: no cover 

57 _M = Mapping[str, str] 

58 _MM = MutableMapping[str, str] 

59else: 

60 _M = Mapping 

61 _MM = MutableMapping 

62 

63 

64class BooleanConfigRegistry: 

65 """Mixin class for boolean operations.""" 

66 

67 TRUE = frozenset({'yes', 'true', '1', 'enable', 'enabled', 'on'}) 

68 FALSE = frozenset({'no', 'false', '0', 'disable', 'disabled', 'off'}) 

69 

70 def is_true(self, key: str | None = None, default: bool = False, value: str | None = None) -> bool: 

71 """ 

72 Return if the strings value of key is considered as true. 

73 

74 :param key: UCR variable name. 

75 :param default: Default value to return, if UCR variable is not set. 

76 :param value: text string to directly evaluate instead of looking up the key. 

77 :returns: `True` when the value is one of `yes`, `true`, `1`, `enable`, `enabled`, `on`. 

78 

79 >>> ucr = ConfigRegistry('/dev/null') 

80 >>> ucr['key'] = 'yes' 

81 >>> ucr.is_true('key') 

82 True 

83 >>> ucr.is_true('other') 

84 False 

85 >>> ucr.is_true('other', True) 

86 True 

87 >>> ucr.is_true(value='1') 

88 True 

89 """ 

90 if value is None: 

91 value = self.get(key) # type: ignore 

92 if value is None: 

93 return default 

94 return value.lower() in self.TRUE 

95 

96 def is_false(self, key: str | None = None, default: bool = False, value: str | None = None) -> bool: 

97 """ 

98 Return if the strings value of key is considered as false. 

99 

100 :param key: UCR variable name. 

101 :param default: Default value to return, if UCR variable is not set. 

102 :param value: text string to directly evaulate instead of looking up the key. 

103 :returns: `True` when the value is one of `no`, `false`, `0`, `disable`, `disabled`, `off`. 

104 

105 >>> ucr = ConfigRegistry('/dev/null') 

106 >>> ucr['key'] = 'no' 

107 >>> ucr.is_false('key') 

108 True 

109 >>> ucr.is_false('other') 

110 False 

111 >>> ucr.is_false('other', True) 

112 True 

113 >>> ucr.is_false(value='0') 

114 True 

115 """ 

116 if value is None: 

117 value = self.get(key) # type: ignore 

118 if value is None: 

119 return default 

120 return value.lower() in self.FALSE 

121 

122 

123class ViewConfigRegistry(_M, BooleanConfigRegistry): 

124 """Immutable view of UCR.""" 

125 

126 def __init__(self, ucr: Mapping[str, str]) -> None: 

127 self.ucr = ucr 

128 

129 def __getitem__(self, key: str) -> str: 

130 return self.ucr.__getitem__(key) 

131 

132 def __iter__(self) -> Iterator[str]: 

133 return self.ucr.__iter__() 

134 

135 def __len__(self) -> int: 

136 return self.ucr.__len__() 

137 

138 

139class ReadOnlyConfigRegistry(_M, BooleanConfigRegistry): 

140 """ 

141 Merged persistent read-only value store. 

142 This is a merged view of several sub-registries. 

143 

144 :param filename: File name for custom layer text database file. 

145 """ 

146 

147 DEFAULTS, NORMAL, LDAP, SCHEDULE, FORCED, CUSTOM = range(6) 

148 LAYER_PRIORITIES = (CUSTOM, FORCED, SCHEDULE, LDAP, NORMAL, DEFAULTS) 

149 PREFIX = '/etc/univention' 

150 BASES = { 

151 NORMAL: 'base.conf', 

152 LDAP: 'base-ldap.conf', 

153 SCHEDULE: 'base-schedule.conf', 

154 FORCED: 'base-forced.conf', 

155 DEFAULTS: 'base-defaults.conf', 

156 } 

157 

158 def __init__(self, filename: str = "") -> None: 

159 super().__init__() 

160 custom = os.getenv('UNIVENTION_BASECONF') or filename 

161 self.autoload = Load.MANUAL 

162 

163 self._registry: dict[int, _ConfigRegistry] = {} 

164 for reg in self.LAYER_PRIORITIES: 

165 if reg == self.CUSTOM: 

166 self._registry[reg] = _ConfigRegistry(custom or os.devnull) 

167 else: 

168 self._registry[reg] = _ConfigRegistry(os.devnull if custom else os.path.join(self.PREFIX, self.BASES[reg])) 

169 

170 def _walk(self) -> Iterator[tuple[int, Self]]: 

171 """ 

172 Iterator over layers. 

173 

174 :returns: Iterator of 2-tuple (layers-mumber, layer) 

175 """ 

176 if self.autoload: 

177 self.load(Load.MANUAL if self.autoload == Load.ONCE else self.autoload) 

178 

179 for reg in self.LAYER_PRIORITIES: 

180 registry = self._registry[reg] 

181 yield (reg, registry) 

182 

183 def load(self, autoload: Load = Load.MANUAL) -> Self: 

184 """ 

185 Load registry from file. 

186 

187 :param autoload: Automatically reload changed files. 

188 """ 

189 for reg in self._registry.values(): 

190 reg.load() 

191 

192 self.autoload = Load.MANUAL # prevent recursion! 

193 strict = False # self.is_true('ucr/encoding/strict') 

194 self.autoload = autoload 

195 

196 for reg in self._registry.values(): 

197 reg.strict_encoding = strict 

198 

199 return self 

200 

201 def __enter__(self) -> ViewConfigRegistry: 

202 """ 

203 Return immutable view despite `autoload`. 

204 

205 :returns: A frozen registry. 

206 

207 > ucr_live = ConfigRegistry().load(autoload=Load.ALWAYS) 

208 > with ucr_live as ucr_frozen: 

209 > for key, value in ucr_frozen.items(): 

210 > print(key, value) 

211 """ 

212 return ViewConfigRegistry(self._merge()) 

213 

214 def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None: 

215 """Release registry view.""" 

216 

217 def __getitem__(self, key: str) -> str | None: # type: ignore 

218 """ 

219 Return registry value. 

220 

221 :param key: UCR variable name. 

222 :returns: the value or `None`. 

223 

224 Bug #28276: ucr[key] returns None instead of raising KeyError - it would break many UCR templates! 

225 """ 

226 return self.get(key) 

227 

228 def __contains__(self, key: str) -> bool: # type: ignore 

229 """ 

230 Check if registry key is set. 

231 

232 :param key: UCR variable name. 

233 :returns: `True` is set, `False` otherwise. 

234 """ 

235 return any(key in registry for _reg, registry in self._walk()) 

236 

237 def __iter__(self) -> Iterator[str]: 

238 """ 

239 Iterate over all registry keys. 

240 

241 :returns: Iterator over all UCR variable names. 

242 """ 

243 yield from self._merge() 

244 

245 def __len__(self) -> int: 

246 """ 

247 Return length. 

248 

249 :returns: Number of UCR variables set. 

250 """ 

251 merge = self._merge() 

252 return len(merge) 

253 

254 @overload # type: ignore 

255 def get(self, key: str, default: _VT, getscope: Literal[True]) -> tuple[int, str] | _VT: # pragma: no cover 

256 pass 

257 

258 @overload 

259 def get(self, key: str, default: _VT | None = None) -> str | _VT: # pragma: no cover 

260 pass 

261 

262 def get(self, key: str, default: _VT | None = None, getscope: bool = False) -> str | tuple[int, str] | _VT | None: 

263 """ 

264 Return registry value (including optional scope). 

265 

266 :param key: UCR variable name. 

267 :param default: Default value when the UCR variable is not set. 

268 :param getscope: `True` makes the method return the scope level in addition to the value itself. 

269 :returns: the value or a 2-tuple (level, value) or the default. 

270 """ 

271 for reg, registry in self._walk(): 

272 try: 

273 value: str = registry[key] 

274 except KeyError: 

275 continue 

276 if reg == self.DEFAULTS: 

277 value = self._eval_default(value) 

278 return (reg, value) if getscope else value 

279 return default 

280 

281 @overload 

282 def get_int(self, key: str) -> int | None: # pragma: no cover 

283 pass 

284 

285 @overload # type: ignore 

286 def get_int(self, key: str, default: _VT) -> int | _VT: # pragma: no cover 

287 pass 

288 

289 def get_int(self, key: str, default: _VT | None = None) -> int | _VT | None: 

290 """ 

291 Return registry value as int. 

292 

293 :param key: UCR variable name. 

294 :param default: Default value when the UCR variable is not set. 

295 :returns: the registry value or the default. 

296 """ 

297 try: 

298 return int(self[key]) # type: ignore 

299 except (KeyError, TypeError, ValueError): 

300 return default 

301 

302 @overload 

303 def _merge(self) -> dict[str, str]: # pragma: no cover 

304 pass 

305 

306 @overload 

307 def _merge(self, getscope: Literal[True]) -> dict[str, tuple[int, str]]: # pragma: no cover 

308 pass 

309 

310 def _merge(self, getscope: bool = False) -> dict[str, str] | dict[str, tuple[int, str]]: 

311 """ 

312 Merge sub registry. 

313 

314 :param getscope: `True` makes the method return the scope level in addition to the value itself. 

315 :returns: A mapping from varibal ename to eiter the value (if `getscope` is False) or a 2-tuple (level, value). 

316 """ 

317 merge: dict[str, str | tuple[int, str]] = {} 

318 for reg, registry in self._walk(): 

319 for key, value in registry.items(): 

320 if key not in merge: 

321 if reg == self.DEFAULTS: 

322 value = self._eval_default(value) 

323 merge[key] = (reg, value) if getscope else value 

324 

325 return merge # type: ignore 

326 

327 def _eval_default(self, default: str) -> str: 

328 """ 

329 Recursively evaluate default value. 

330 

331 :param default: Default value. 

332 :returns: Substituted value. 

333 """ 

334 try: 

335 value = run_filter(default, self, opts={'disallow-execution': True}) 

336 except RuntimeError: # maximum recursion depth exceeded 

337 value = b'' 

338 

339 return value.decode("UTF-8") 

340 

341 @overload 

342 def items(self) -> ItemsView[str, str]: # pragma: no cover 

343 pass 

344 

345 @overload 

346 def items(self, getscope: Literal[True]) -> ItemsView[str, tuple[int, str]]: # pragma: no cover 

347 pass 

348 

349 def items(self, getscope: bool = False) -> ItemsView[str, str] | ItemsView[str, tuple[int, str]]: 

350 """ 

351 Return all registry entries a 2-tuple (key, value) or (key, (scope, value)) if getscope is True. 

352 

353 :param getscope: `True` makes the method return the scope level in addition to the value itself. 

354 :returns: A mapping from varibal ename to eiter the value (if `getscope` is False) or a 2-tuple (level, value). 

355 """ 

356 merge = self._merge(getscope=getscope) 

357 return merge.items() # type: ignore 

358 

359 def __str__(self) -> str: 

360 """Return registry content as string.""" 

361 merge = self._merge() 

362 return '\n'.join(['%s: %s' % (key, val) for key, val in merge.items()]) 

363 

364 

365class ConfigRegistry(ReadOnlyConfigRegistry, _MM): 

366 """ 

367 Merged persistent value store. 

368 This is a merged view of several sub-registries. 

369 

370 :param filename: File name for custom layer text database file. 

371 :param write_registry: The UCR level used for writing. 

372 """ 

373 

374 def __init__(self, filename: str = "", write_registry: int = ReadOnlyConfigRegistry.NORMAL) -> None: 

375 super().__init__(filename) 

376 custom = os.getenv('UNIVENTION_BASECONF') or filename 

377 self.scope = self.CUSTOM if custom else write_registry 

378 for reg in self.LAYER_PRIORITIES: 

379 registry = self._registry[reg] 

380 registry._create_base_conf() 

381 

382 @property 

383 def _layer(self) -> Self: 

384 """Return selected layer.""" 

385 return self._registry[self.scope] 

386 

387 def save(self) -> None: 

388 """Save registry to file.""" 

389 self._layer.save() 

390 

391 def lock(self) -> None: 

392 """Lock registry file.""" 

393 self._layer.lock() 

394 

395 def unlock(self) -> None: 

396 """Un-lock registry file.""" 

397 self._layer.unlock() 

398 

399 def __enter__(self) -> Self: # type: ignore 

400 """ 

401 Lock Config Registry for read-modify-write cycle. 

402 

403 :returns: The locked registry. 

404 

405 > with ConfigRegistry() as ucr: 

406 > ucr['key'] = 'value' 

407 """ 

408 self.lock() 

409 self.load() 

410 return self 

411 

412 def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None: 

413 """Unlock registry.""" 

414 if exc_type is None: 

415 self.save() 

416 self.unlock() 

417 

418 def clear(self) -> None: 

419 """Clear all registry keys.""" 

420 self._layer.clear() 

421 

422 def __delitem__(self, key: str) -> None: 

423 """ 

424 Delete registry key. 

425 

426 :param key: UCR variable name. 

427 """ 

428 del self._layer[key] 

429 

430 def __setitem__(self, key: str, value: str) -> None: 

431 """ 

432 Set registry value. 

433 

434 :param key: UCR variable name. 

435 :param value: UCR variable value. 

436 """ 

437 self._layer[key] = value 

438 

439 def update(self, changes: dict[str, str | None]) -> dict[str, tuple[str | None, str | None]]: # type: ignore 

440 """ 

441 Set or unset the given config registry variables. 

442 

443 :param changes: dictionary of ucr-variable-name: value-or-None. 

444 :returns: A mapping from UCR variable name to a 2-tuple (old-value, new-value) 

445 """ 

446 registry = self._layer 

447 changed = {} 

448 for key, value in changes.items(): 

449 old_value = registry.get(key, None) 

450 if value is None: 

451 try: 

452 del registry[key] 

453 except KeyError: 

454 continue 

455 else: 

456 registry[key] = value 

457 new_value = registry.get(key, value) 

458 changed[key] = (old_value, new_value) 

459 return changed 

460 

461 def setdefault(self, key: str, default: str) -> str: # type: ignore 

462 """ 

463 Set value for variable only when not yet set. 

464 

465 :param key: UCR variable name. 

466 :param default: UCR variable value. 

467 :returns: The old value, if the variable was not yet set, otherwise the new value. 

468 """ 

469 # Bug #28276: setdefault() required KeyError 

470 value = self.get(key, default=self) 

471 if value is self: 471 ↛ 474line 471 didn't jump to line 474 because the condition on line 471 was always true

472 value = self[key] = default 

473 

474 return value # type: ignore 

475 

476 

477class _ConfigRegistry(dict): 

478 """ 

479 Persistent value store. 

480 This is a single value store using a text file. 

481 

482 :param filename: File name for text database file. 

483 """ 

484 

485 RE_COMMENT = re.compile(r'^[^:]*#.*$') 

486 

487 def __init__(self, filename: str) -> None: 

488 dict.__init__(self) 

489 self.file = filename 

490 self.backup_file = self.file + '.bak' 

491 self.lock_filename = self.file + '.lock' 

492 # will be set by <ConfigRegistry> for each <_ConfigRegistry> - <True> 

493 # means the backend files are valid UTF-8 and should stay that way --> 

494 # only accept valid UTF-8 

495 self.strict_encoding = False 

496 self.lock_file: IO | None = None 

497 self.mtime = 0.0 

498 

499 def load(self) -> None: 

500 """Load sub registry from file.""" 

501 for fn in (self.file, self.backup_file): 

502 new = {} 

503 try: 

504 file_stat = os.stat(fn) 

505 if file_stat.st_mtime <= self.mtime and fn == self.file: 

506 return 

507 

508 with open(fn, encoding='utf-8') as reg_file: 

509 if reg_file.readline() == '' or reg_file.readline() == '': 

510 continue 

511 

512 reg_file.seek(0) 

513 for line in reg_file: 

514 line = self.RE_COMMENT.sub("", line) 

515 if line == '': 

516 continue 

517 if line.find(': ') == -1: 

518 continue 

519 

520 key, value = line.split(': ', 1) 

521 new[key] = value.strip() 

522 

523 break 

524 except OSError: 

525 pass 

526 else: 

527 return 

528 

529 self.mtime = file_stat.st_mtime 

530 self.update(new) 

531 for key in set(self.keys()) - set(new.keys()): 531 ↛ 532line 531 didn't jump to line 532 because the loop on line 531 never started

532 self.pop(key, None) 

533 

534 if fn != self.file: 

535 self._save_file(self.file) 

536 

537 def _create_base_conf(self) -> None: 

538 """Create sub registry file.""" 

539 try: 

540 reg_file = os.open(self.file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) 

541 os.close(reg_file) 

542 except OSError as ex: 

543 if ex.errno == errno.EEXIST and not os.path.isdir(self.file): 

544 return 

545 msg = "E: could not create file '%s': %s" % (self.file, ex) 

546 print(msg, file=sys.stderr) 

547 exception_occured() 

548 

549 def _save_file(self, filename: str) -> None: 

550 """ 

551 Save sub registry to file. 

552 

553 :param filename: File name for saving. 

554 :raises EnvironmentError: on fatal errors. 

555 """ 

556 temp_filename = '%s.temp' % filename 

557 try: 

558 try: 

559 file_stat = os.stat(filename) 

560 if not S_ISREG(file_stat.st_mode): 

561 return 

562 except OSError: 

563 file_stat = os.stat_result((0o0644, -1, -1, -1, 0, 0, -1, -1, -1, -1)) 

564 

565 # open temporary file for writing 

566 self._save_to(temp_filename) 

567 try: 

568 os.chmod(temp_filename, file_stat.st_mode) 

569 os.chown(temp_filename, file_stat.st_uid, file_stat.st_gid) 

570 os.rename(temp_filename, filename) 

571 except OSError as ex: 

572 if ex.errno == errno.EBUSY: 572 ↛ 573line 572 didn't jump to line 573 because the condition on line 572 was never true

573 with open(filename, 'w+', encoding='utf-8') as fd: 

574 fd.write(open(temp_filename, encoding='utf-8').read()) 

575 os.unlink(temp_filename) 

576 else: 

577 # In this case the temp file created above in this 

578 # function was already moved by a concurrent UCR 

579 # operation. Dump the current state to a backup file 

580 temp_filename = '%s.concurrent_%s' % (filename, time.time()) 

581 self._save_to(temp_filename) 

582 except OSError as ex: 

583 # suppress certain errors 

584 if ex.errno != errno.EACCES: 

585 raise 

586 

587 def _save_to(self, filename: str) -> None: 

588 """ 

589 Serialize sub registry to file. 

590 

591 :param filename: File name for saving. 

592 """ 

593 with open(filename, 'w', encoding='utf-8') as fd: 

594 # write data to file 

595 fd.write('# univention_ base.conf\n\n') 

596 fd.write(self.__unicode__()) 

597 # flush (meta)data 

598 fd.flush() 

599 os.fsync(fd.fileno()) 

600 

601 def save(self) -> None: 

602 """Save sub registry to file.""" 

603 for filename in (self.backup_file, self.file): 

604 self._save_file(filename) 

605 

606 def lock(self) -> None: 

607 """Lock sub registry file.""" 

608 self.lock_file = lock = open(self.lock_filename, "a+", encoding='utf-8') 

609 fcntl.flock(lock.fileno(), fcntl.LOCK_EX) 

610 

611 def unlock(self) -> None: 

612 """Un-lock sub registry file.""" 

613 if self.lock_file is not None: 613 ↛ exitline 613 didn't return from function 'unlock' because the condition on line 613 was always true

614 self.lock_file.close() 

615 self.lock_file = None 

616 

617 def __setitem__(self, key, value): 

618 """ 

619 Set value in sub registry. 

620 

621 :param key: UCR variable name. 

622 :param value: UCR variable value. 

623 """ 

624 if self.strict_encoding: 624 ↛ 625line 624 didn't jump to line 625 because the condition on line 624 was never true

625 try: 

626 key.decode('UTF-8') # only accept valid UTF-8 encoded bytes 

627 except UnicodeError: 

628 raise StrictModeException('variable name is not UTF-8 encoded') 

629 try: 

630 value.decode('UTF-8') # only accept valid UTF-8 encoded bytes 

631 except UnicodeError: 

632 raise StrictModeException('value is not UTF-8 encoded') 

633 return dict.__setitem__(self, key, value) 

634 

635 @staticmethod 

636 def remove_invalid_chars(seq: str) -> str: 

637 """ 

638 Remove non-UTF-8 characters from value. 

639 

640 :param seq: Text string. 

641 :returns: Text string with invalid characters removed. 

642 """ 

643 for letter in INVALID_VALUE_CHARS: 

644 seq = seq.replace(letter, '') 

645 return seq 

646 

647 def __str__(self) -> str: 

648 """Return sub registry content as string.""" 

649 return '\n'.join( 

650 '%s: %s' % (key, self.remove_invalid_chars(val)) 

651 for key, val in sorted(self.items()) 

652 ) 

653 

654 def __unicode__(self): # noqa: PLW3201 

655 data = self.__str__() 

656 if isinstance(data, bytes): 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true

657 data = data.decode('UTF-8') 

658 return data