Coverage for src/debputy/manifest_parser/base_types.py: 73%

262 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-02-14 10:41 +0000

1import dataclasses 

2import os 

3import subprocess 

4from functools import lru_cache 

5from typing import Literal, TYPE_CHECKING, NotRequired 

6from collections.abc import Sequence, Mapping, Iterable, MutableMapping 

7 

8from debputy.manifest_conditions import ManifestCondition 

9from debputy.manifest_parser.exceptions import ManifestParseException 

10from debputy.manifest_parser.tagging_types import DebputyParsedContent 

11from debputy.manifest_parser.util import ( 

12 AttributePath, 

13 _SymbolicModeSegment, 

14 parse_symbolic_mode, 

15) 

16from debputy.path_matcher import MatchRule, ExactFileSystemPath 

17from debputy.substitution import NULL_SUBSTITUTION, Substitution 

18from debputy.util import ( 

19 _normalize_path, 

20 _error, 

21 _warn, 

22 _debug_log, 

23 _is_debug_log_enabled, 

24) 

25 

26if TYPE_CHECKING: 

27 from debputy.manifest_parser.parser_data import ParserContextData 

28 

29 

30@dataclasses.dataclass(slots=True, frozen=True) 

31class OwnershipDefinition: 

32 entity_name: str 

33 entity_id: int 

34 

35 

36class DebputyParsedContentStandardConditional(DebputyParsedContent): 

37 when: NotRequired[ManifestCondition] 

38 

39 

40ROOT_DEFINITION = OwnershipDefinition("root", 0) 

41 

42 

43BAD_OWNER_NAMES = { 

44 "_apt", # All things owned by _apt are generated by apt after installation 

45 "nogroup", # It is not supposed to own anything as it is an entity used for dropping permissions 

46 "nobody", # It is not supposed to own anything as it is an entity used for dropping permissions 

47} 

48BAD_OWNER_IDS = { 

49 65534, # ID of nobody / nogroup 

50} 

51 

52 

53def _parse_ownership( 

54 v: str | int, 

55 attribute_path: AttributePath, 

56) -> tuple[str | None, int | None]: 

57 if isinstance(v, str) and ":" in v: 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true

58 if v == ":": 

59 raise ManifestParseException( 

60 f'Invalid ownership value "{v}" at {attribute_path.path}: Ownership is redundant if it is ":"' 

61 f" (blank name and blank id). Please provide non-default values or remove the definition." 

62 ) 

63 entity_name: str | None 

64 entity_id: int | None 

65 entity_name, entity_id_str = v.split(":") 

66 if entity_name == "": 

67 entity_name = None 

68 if entity_id_str != "": 

69 entity_id = int(entity_id_str) 

70 else: 

71 entity_id = None 

72 return entity_name, entity_id 

73 

74 if isinstance(v, int): 

75 return None, v 

76 if v.isdigit(): 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true

77 raise ManifestParseException( 

78 f'Invalid ownership value "{v}" at {attribute_path.path}: The provided value "{v}" is a string (implying' 

79 " name lookup), but it contains an integer (implying id lookup). Please use a regular int for id lookup" 

80 f' (removing the quotes) or add a ":" in the end ("{v}:") as a disambiguation if you are *really* looking' 

81 " for an entity with that name." 

82 ) 

83 return v, None 

84 

85 

86@lru_cache 

87def _load_ownership_table_from_file( 

88 name: Literal["passwd.master", "group.master"], 

89) -> tuple[Mapping[str, OwnershipDefinition], Mapping[int, OwnershipDefinition]]: 

90 filename = os.path.join("/usr/share/base-passwd", name) 

91 name_table = {} 

92 uid_table = {} 

93 for owner_def in _read_ownership_def_from_base_password_template(filename): 

94 # Could happen if base-passwd template has two users with the same ID. We assume this will not occur. 

95 assert owner_def.entity_name not in name_table 

96 assert owner_def.entity_id not in uid_table 

97 name_table[owner_def.entity_name] = owner_def 

98 uid_table[owner_def.entity_id] = owner_def 

99 

100 return name_table, uid_table 

101 

102 

103def _read_ownership_def_from_base_password_template( 

104 template_file: str, 

105) -> Iterable[OwnershipDefinition]: 

106 with open(template_file) as fd: 

107 for line in fd: 

108 entity_name, _star, entity_id, _remainder = line.split(":", 3) 

109 if entity_id == "0" and entity_name == "root": 

110 yield ROOT_DEFINITION 

111 else: 

112 yield OwnershipDefinition(entity_name, int(entity_id)) 

113 

114 

115class FileSystemMode: 

116 @classmethod 

117 def parse_filesystem_mode( 

118 cls, 

119 mode_raw: str, 

120 attribute_path: AttributePath, 

121 ) -> "FileSystemMode": 

122 if mode_raw and mode_raw[0].isdigit(): 

123 return OctalMode.parse_filesystem_mode(mode_raw, attribute_path) 

124 return SymbolicMode.parse_filesystem_mode(mode_raw, attribute_path) 

125 

126 def compute_mode(self, current_mode: int, is_dir: bool) -> int: 

127 raise NotImplementedError 

128 

129 

130@dataclasses.dataclass(slots=True, frozen=True) 

131class SymbolicMode(FileSystemMode): 

132 provided_mode: str 

133 segments: Sequence[_SymbolicModeSegment] 

134 

135 @classmethod 

136 def parse_filesystem_mode( 

137 cls, 

138 mode_raw: str, 

139 attribute_path: AttributePath, 

140 ) -> "SymbolicMode": 

141 segments = list(parse_symbolic_mode(mode_raw, attribute_path)) 

142 return SymbolicMode(mode_raw, segments) 

143 

144 def __str__(self) -> str: 

145 return self.symbolic_mode() 

146 

147 @property 

148 def is_symbolic_mode(self) -> bool: 

149 return False 

150 

151 def symbolic_mode(self) -> str: 

152 return self.provided_mode 

153 

154 def compute_mode(self, current_mode: int, is_dir: bool) -> int: 

155 final_mode = current_mode 

156 for segment in self.segments: 

157 final_mode = segment.apply(final_mode, is_dir) 

158 return final_mode 

159 

160 

161@dataclasses.dataclass(slots=True, frozen=True) 

162class OctalMode(FileSystemMode): 

163 octal_mode: int 

164 

165 @classmethod 

166 def parse_filesystem_mode( 

167 cls, 

168 mode_raw: str, 

169 attribute_path: AttributePath, 

170 ) -> "FileSystemMode": 

171 try: 

172 mode = int(mode_raw, base=8) 

173 except ValueError as e: 

174 error_msg = 'An octal mode must be all digits between 0-7 (such as "644")' 

175 raise ManifestParseException( 

176 f"Cannot parse {attribute_path.path} as an octal mode: {error_msg}" 

177 ) from e 

178 return OctalMode(mode) 

179 

180 @property 

181 def is_octal_mode(self) -> bool: 

182 return True 

183 

184 def compute_mode(self, _current_mode: int, _is_dir: bool) -> int: 

185 return self.octal_mode 

186 

187 def __str__(self) -> str: 

188 return f"0{oct(self.octal_mode)[2:]}" 

189 

190 

191@dataclasses.dataclass(slots=True, frozen=True) 

192class _StaticFileSystemOwnerGroup: 

193 ownership_definition: OwnershipDefinition 

194 

195 @property 

196 def entity_name(self) -> str: 

197 return self.ownership_definition.entity_name 

198 

199 @property 

200 def entity_id(self) -> int: 

201 return self.ownership_definition.entity_id 

202 

203 @classmethod 

204 def from_manifest_value( 

205 cls, 

206 raw_input: str | int, 

207 attribute_path: AttributePath, 

208 ) -> "_StaticFileSystemOwnerGroup": 

209 provided_name, provided_id = _parse_ownership(raw_input, attribute_path) 

210 owner_def = cls._resolve(raw_input, provided_name, provided_id, attribute_path) 

211 if ( 211 ↛ 215line 211 didn't jump to line 215 because the condition on line 211 was never true

212 owner_def.entity_name in BAD_OWNER_NAMES 

213 or owner_def.entity_id in BAD_OWNER_IDS 

214 ): 

215 raise ManifestParseException( 

216 f'Refusing to use "{raw_input}" as {cls._owner_type()} (defined at {attribute_path.path})' 

217 f' as it resolves to "{owner_def.entity_name}:{owner_def.entity_id}" and no path should have this' 

218 f" entity as {cls._owner_type()} as it is unsafe." 

219 ) 

220 return cls(owner_def) 

221 

222 @classmethod 

223 def _resolve( 

224 cls, 

225 raw_input: str | int, 

226 provided_name: str | None, 

227 provided_id: int | None, 

228 attribute_path: AttributePath, 

229 ) -> OwnershipDefinition: 

230 table_name = cls._ownership_table_name() 

231 name_table, id_table = _load_ownership_table_from_file(table_name) 

232 name_match = ( 

233 name_table.get(provided_name) if provided_name is not None else None 

234 ) 

235 id_match = id_table.get(provided_id) if provided_id is not None else None 

236 if id_match is None and name_match is None: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true

237 name_part = provided_name if provided_name is not None else "N/A" 

238 id_part = provided_id if provided_id is not None else "N/A" 

239 raise ManifestParseException( 

240 f'Cannot resolve "{raw_input}" as {cls._owner_type()} (from {attribute_path.path}):' 

241 f" It is not known to be a static {cls._owner_type()} from base-passwd." 

242 f' The value was interpreted as name: "{name_part}" and id: {id_part}' 

243 ) 

244 if id_match is None: 

245 assert name_match is not None 

246 return name_match 

247 if name_match is None: 247 ↛ 250line 247 didn't jump to line 250 because the condition on line 247 was always true

248 assert id_match is not None 

249 return id_match 

250 if provided_name != id_match.entity_name: 

251 raise ManifestParseException( 

252 f"Bad {cls._owner_type()} declaration: The id {provided_id} resolves to {id_match.entity_name}" 

253 f" according to base-passwd, but the packager declared to should have been {provided_name}" 

254 f" at {attribute_path.path}" 

255 ) 

256 if provided_id != name_match.entity_id: 

257 raise ManifestParseException( 

258 f"Bad {cls._owner_type()} declaration: The name {provided_name} resolves to {name_match.entity_id}" 

259 f" according to base-passwd, but the packager declared to should have been {provided_id}" 

260 f" at {attribute_path.path}" 

261 ) 

262 return id_match 

263 

264 @classmethod 

265 def _owner_type(cls) -> Literal["owner", "group"]: 

266 raise NotImplementedError 

267 

268 @classmethod 

269 def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]: 

270 raise NotImplementedError 

271 

272 

273class StaticFileSystemOwner(_StaticFileSystemOwnerGroup): 

274 @classmethod 

275 def _owner_type(cls) -> Literal["owner", "group"]: 

276 return "owner" 

277 

278 @classmethod 

279 def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]: 

280 return "passwd.master" 

281 

282 

283class StaticFileSystemGroup(_StaticFileSystemOwnerGroup): 

284 @classmethod 

285 def _owner_type(cls) -> Literal["owner", "group"]: 

286 return "group" 

287 

288 @classmethod 

289 def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]: 

290 return "group.master" 

291 

292 

293@dataclasses.dataclass(slots=True, frozen=True) 

294class SymlinkTarget: 

295 raw_symlink_target: str 

296 attribute_path: AttributePath 

297 symlink_target: str 

298 

299 @classmethod 

300 def parse_symlink_target( 

301 cls, 

302 raw_symlink_target: str, 

303 attribute_path: AttributePath, 

304 substitution: Substitution, 

305 ) -> "SymlinkTarget": 

306 return SymlinkTarget( 

307 raw_symlink_target, 

308 attribute_path, 

309 substitution.substitute(raw_symlink_target, attribute_path.path), 

310 ) 

311 

312 

313class FileSystemMatchRule: 

314 @property 

315 def raw_match_rule(self) -> str: 

316 raise NotImplementedError 

317 

318 @property 

319 def attribute_path(self) -> AttributePath: 

320 raise NotImplementedError 

321 

322 @property 

323 def match_rule(self) -> MatchRule: 

324 raise NotImplementedError 

325 

326 @classmethod 

327 def parse_path_match( 

328 cls, 

329 raw_match_rule: str, 

330 attribute_path: AttributePath, 

331 parser_context: "ParserContextData | None", 

332 ) -> "FileSystemMatchRule": 

333 if parser_context is None: 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true

334 subs = NULL_SUBSTITUTION 

335 else: 

336 subs = parser_context.substitution 

337 return cls.from_path_match(raw_match_rule, attribute_path, subs) 

338 

339 @classmethod 

340 def from_path_match( 

341 cls, 

342 raw_match_rule: str, 

343 attribute_path: AttributePath, 

344 substitution: "Substitution", 

345 ) -> "FileSystemMatchRule": 

346 try: 

347 mr = MatchRule.from_path_or_glob( 

348 raw_match_rule, 

349 attribute_path.path, 

350 substitution=substitution, 

351 ) 

352 except ValueError as e: 

353 raise ManifestParseException( 

354 f'Could not parse "{raw_match_rule}" (defined at {attribute_path.path})' 

355 f" as a path or a glob: {e.args[0]}" 

356 ) 

357 

358 if isinstance(mr, ExactFileSystemPath): 

359 return FileSystemExactMatchRule( 

360 raw_match_rule, 

361 attribute_path, 

362 mr, 

363 ) 

364 return FileSystemGenericMatch( 

365 raw_match_rule, 

366 attribute_path, 

367 mr, 

368 ) 

369 

370 

371@dataclasses.dataclass(slots=True, frozen=True) 

372class FileSystemGenericMatch(FileSystemMatchRule): 

373 raw_match_rule: str 

374 attribute_path: AttributePath 

375 match_rule: MatchRule 

376 

377 

378@dataclasses.dataclass(slots=True, frozen=True) 

379class FileSystemExactMatchRule(FileSystemMatchRule): 

380 raw_match_rule: str 

381 attribute_path: AttributePath 

382 match_rule: ExactFileSystemPath 

383 

384 @classmethod 

385 def from_path_match( 

386 cls, 

387 raw_match_rule: str, 

388 attribute_path: AttributePath, 

389 substitution: "Substitution", 

390 ) -> "FileSystemExactMatchRule": 

391 try: 

392 normalized = _normalize_path(raw_match_rule) 

393 except ValueError as e: 

394 raise ManifestParseException( 

395 f'The path "{raw_match_rule}" provided in {attribute_path.path} should be relative to the' 

396 ' root of the package and not use any ".." or "." segments.' 

397 ) from e 

398 if normalized == ".": 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true

399 raise ManifestParseException( 

400 f'The path "{raw_match_rule}" matches a file system root and that is not a valid match' 

401 f' at "{attribute_path.path}". Please narrow the provided path.' 

402 ) 

403 mr = ExactFileSystemPath( 

404 substitution.substitute(normalized, attribute_path.path) 

405 ) 

406 if mr.path.endswith("/") and issubclass(cls, FileSystemExactNonDirMatchRule): 406 ↛ 407line 406 didn't jump to line 407 because the condition on line 406 was never true

407 raise ManifestParseException( 

408 f'The path "{raw_match_rule}" at {attribute_path.path} resolved to' 

409 f' "{mr.path}". Since the resolved path ends with a slash ("/"), this' 

410 " means only a directory can match. However, this attribute should" 

411 " match a *non*-directory" 

412 ) 

413 return cls( 

414 raw_match_rule, 

415 attribute_path, 

416 mr, 

417 ) 

418 

419 

420class FileSystemExactNonDirMatchRule(FileSystemExactMatchRule): 

421 pass 

422 

423 

424class DpkgBuildflagsCache: 

425 

426 __slots__ = ("_cache_keys",) 

427 

428 def __init__(self) -> None: 

429 self._cache_keys: dict[frozenset[tuple[str, str]], Mapping[str, str]] = {} 

430 

431 def run_dpkg_buildflags( 

432 self, 

433 env: Mapping[str, str], 

434 definition_source: str | None, 

435 ) -> Mapping[str, str]: 

436 cache_key = frozenset((k, v) for k, v in env.items() if k.startswith("DEB_")) 

437 dpkg_env = self._cache_keys.get(cache_key) 

438 if dpkg_env is not None: 

439 return dpkg_env 

440 dpkg_env = {} 

441 try: 

442 bf_output = subprocess.check_output(["dpkg-buildflags"], env=env) 

443 except FileNotFoundError: 

444 if definition_source is None: 

445 _error( 

446 "The dpkg-buildflags command was not available and is necessary to set the relevant" 

447 "env variables by default." 

448 ) 

449 _error( 

450 "The dpkg-buildflags command was not available and is necessary to set the relevant" 

451 f"env variables for the environment defined at {definition_source}." 

452 ) 

453 except subprocess.CalledProcessError as e: 

454 if definition_source is None: 

455 _error( 

456 f"The dpkg-buildflags command failed with exit code {e.returncode}. Please review the output from" 

457 f" dpkg-buildflags above to resolve the issue." 

458 ) 

459 _error( 

460 f"The dpkg-buildflags command failed with exit code {e.returncode}. Please review the output from" 

461 f" dpkg-buildflags above to resolve the issue. The environment definition that triggered this call" 

462 f" was {definition_source}" 

463 ) 

464 else: 

465 warned = False 

466 for line in bf_output.decode("utf-8").splitlines(keepends=False): 

467 if "=" not in line or line.startswith("="): 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true

468 if not warned: 

469 _warn( 

470 f"Unexpected output from dpkg-buildflags (not a K=V line): {line}" 

471 ) 

472 continue 

473 k, v = line.split("=", 1) 

474 if k.strip() != k: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true

475 if not warned: 

476 _warn( 

477 f'Unexpected output from dpkg-buildflags (Key had spaces): "{line}"' 

478 ) 

479 continue 

480 dpkg_env[k] = v 

481 self._cache_keys[cache_key] = dpkg_env 

482 return dpkg_env 

483 

484 

485_DPKG_BUILDFLAGS_CACHE = DpkgBuildflagsCache() 

486del DpkgBuildflagsCache 

487 

488 

489class BuildEnvironmentDefinition: 

490 

491 def dpkg_buildflags_env( 

492 self, 

493 env: Mapping[str, str], 

494 definition_source: str | None, 

495 ) -> Mapping[str, str]: 

496 return _DPKG_BUILDFLAGS_CACHE.run_dpkg_buildflags(env, definition_source) 

497 

498 def log_computed_env(self, source: str, computed_env: Mapping[str, str]) -> None: 

499 _debug_log(f"Computed environment variables from {source}") 

500 for k, v in computed_env.items(): 

501 _debug_log(f" {k}={v}") 

502 

503 def update_env(self, env: MutableMapping[str, str]) -> None: 

504 dpkg_env = self.dpkg_buildflags_env(env, None) 

505 if _is_debug_log_enabled(): 

506 self.log_computed_env("dpkg-buildflags", dpkg_env) 

507 env.update(dpkg_env) 

508 

509 

510class BuildEnvironments: 

511 

512 def __init__( 

513 self, 

514 environments: dict[str, BuildEnvironmentDefinition], 

515 default_environment: BuildEnvironmentDefinition | None, 

516 ) -> None: 

517 self.environments = environments 

518 self.default_environment = default_environment