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

213 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-04-07 12:14 +0200

1import dataclasses 

2import os 

3from functools import lru_cache 

4from typing import ( 

5 TypedDict, 

6 NotRequired, 

7 Sequence, 

8 Optional, 

9 Union, 

10 Literal, 

11 Tuple, 

12 Mapping, 

13 Iterable, 

14 TYPE_CHECKING, 

15 Callable, 

16 Type, 

17 Generic, 

18) 

19 

20from debputy.manifest_parser.exceptions import ManifestParseException 

21from debputy.manifest_parser.util import ( 

22 AttributePath, 

23 _SymbolicModeSegment, 

24 parse_symbolic_mode, 

25) 

26from debputy.path_matcher import MatchRule, ExactFileSystemPath 

27from debputy.substitution import Substitution 

28from debputy.types import S 

29from debputy.util import _normalize_path, T 

30 

31if TYPE_CHECKING: 

32 from debputy.manifest_conditions import ManifestCondition 

33 from debputy.manifest_parser.parser_data import ParserContextData 

34 

35 

36class DebputyParsedContent(TypedDict): 

37 pass 

38 

39 

40class DebputyDispatchableType: 

41 __slots__ = () 

42 

43 

44class DebputyParsedContentStandardConditional(DebputyParsedContent): 

45 when: NotRequired["ManifestCondition"] 

46 

47 

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

49class OwnershipDefinition: 

50 entity_name: str 

51 entity_id: int 

52 

53 

54@dataclasses.dataclass 

55class TypeMapping(Generic[S, T]): 

56 target_type: Type[T] 

57 source_type: Type[S] 

58 mapper: Callable[[S, AttributePath, Optional["ParserContextData"]], T] 

59 

60 

61ROOT_DEFINITION = OwnershipDefinition("root", 0) 

62 

63 

64BAD_OWNER_NAMES = { 

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

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

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

68} 

69BAD_OWNER_IDS = { 

70 65534, # ID of nobody / nogroup 

71} 

72 

73 

74def _parse_ownership( 

75 v: Union[str, int], 

76 attribute_path: AttributePath, 

77) -> Tuple[Optional[str], Optional[int]]: 

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

79 if v == ":": 

80 raise ManifestParseException( 

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

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

83 ) 

84 entity_name: Optional[str] 

85 entity_id: Optional[int] 

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

87 if entity_name == "": 

88 entity_name = None 

89 if entity_id_str != "": 

90 entity_id = int(entity_id_str) 

91 else: 

92 entity_id = None 

93 return entity_name, entity_id 

94 

95 if isinstance(v, int): 

96 return None, v 

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

98 raise ManifestParseException( 

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

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

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

102 " for an entity with that name." 

103 ) 

104 return v, None 

105 

106 

107@lru_cache 

108def _load_ownership_table_from_file( 

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

110) -> Tuple[Mapping[str, OwnershipDefinition], Mapping[int, OwnershipDefinition]]: 

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

112 name_table = {} 

113 uid_table = {} 

114 for owner_def in _read_ownership_def_from_base_password_template(filename): 

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

116 assert owner_def.entity_name not in name_table 

117 assert owner_def.entity_id not in uid_table 

118 name_table[owner_def.entity_name] = owner_def 

119 uid_table[owner_def.entity_id] = owner_def 

120 

121 return name_table, uid_table 

122 

123 

124def _read_ownership_def_from_base_password_template( 

125 template_file: str, 

126) -> Iterable[OwnershipDefinition]: 

127 with open(template_file) as fd: 

128 for line in fd: 

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

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

131 yield ROOT_DEFINITION 

132 else: 

133 yield OwnershipDefinition(entity_name, int(entity_id)) 

134 

135 

136class FileSystemMode: 

137 @classmethod 

138 def parse_filesystem_mode( 

139 cls, 

140 mode_raw: str, 

141 attribute_path: AttributePath, 

142 ) -> "FileSystemMode": 

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

144 return OctalMode.parse_filesystem_mode(mode_raw, attribute_path) 

145 return SymbolicMode.parse_filesystem_mode(mode_raw, attribute_path) 

146 

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

148 raise NotImplementedError 

149 

150 

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

152class SymbolicMode(FileSystemMode): 

153 provided_mode: str 

154 segments: Sequence[_SymbolicModeSegment] 

155 

156 @classmethod 

157 def parse_filesystem_mode( 

158 cls, 

159 mode_raw: str, 

160 attribute_path: AttributePath, 

161 ) -> "SymbolicMode": 

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

163 return SymbolicMode(mode_raw, segments) 

164 

165 def __str__(self) -> str: 

166 return self.symbolic_mode() 

167 

168 @property 

169 def is_symbolic_mode(self) -> bool: 

170 return False 

171 

172 def symbolic_mode(self) -> str: 

173 return self.provided_mode 

174 

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

176 final_mode = current_mode 

177 for segment in self.segments: 

178 final_mode = segment.apply(final_mode, is_dir) 

179 return final_mode 

180 

181 

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

183class OctalMode(FileSystemMode): 

184 octal_mode: int 

185 

186 @classmethod 

187 def parse_filesystem_mode( 

188 cls, 

189 mode_raw: str, 

190 attribute_path: AttributePath, 

191 ) -> "FileSystemMode": 

192 try: 

193 mode = int(mode_raw, base=8) 

194 except ValueError as e: 

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

196 raise ManifestParseException( 

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

198 ) from e 

199 return OctalMode(mode) 

200 

201 @property 

202 def is_octal_mode(self) -> bool: 

203 return True 

204 

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

206 return self.octal_mode 

207 

208 def __str__(self) -> str: 

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

210 

211 

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

213class _StaticFileSystemOwnerGroup: 

214 ownership_definition: OwnershipDefinition 

215 

216 @property 

217 def entity_name(self) -> str: 

218 return self.ownership_definition.entity_name 

219 

220 @property 

221 def entity_id(self) -> int: 

222 return self.ownership_definition.entity_id 

223 

224 @classmethod 

225 def from_manifest_value( 

226 cls, 

227 raw_input: Union[str, int], 

228 attribute_path: AttributePath, 

229 ) -> "_StaticFileSystemOwnerGroup": 

230 provided_name, provided_id = _parse_ownership(raw_input, attribute_path) 

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

232 if ( 232 ↛ 236line 232 didn't jump to line 236

233 owner_def.entity_name in BAD_OWNER_NAMES 

234 or owner_def.entity_id in BAD_OWNER_IDS 

235 ): 

236 raise ManifestParseException( 

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

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

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

240 ) 

241 return cls(owner_def) 

242 

243 @classmethod 

244 def _resolve( 

245 cls, 

246 raw_input: Union[str, int], 

247 provided_name: Optional[str], 

248 provided_id: Optional[int], 

249 attribute_path: AttributePath, 

250 ) -> OwnershipDefinition: 

251 table_name = cls._ownership_table_name() 

252 name_table, id_table = _load_ownership_table_from_file(table_name) 

253 name_match = ( 

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

255 ) 

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

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

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

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

260 raise ManifestParseException( 

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

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

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

264 ) 

265 if id_match is None: 

266 assert name_match is not None 

267 return name_match 

268 if name_match is None: 268 ↛ 271line 268 didn't jump to line 271, because the condition on line 268 was never false

269 assert id_match is not None 

270 return id_match 

271 if provided_name != id_match.entity_name: 

272 raise ManifestParseException( 

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

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

275 f" at {attribute_path.path}" 

276 ) 

277 if provided_id != name_match.entity_id: 

278 raise ManifestParseException( 

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

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

281 f" at {attribute_path.path}" 

282 ) 

283 return id_match 

284 

285 @classmethod 

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

287 raise NotImplementedError 

288 

289 @classmethod 

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

291 raise NotImplementedError 

292 

293 

294class StaticFileSystemOwner(_StaticFileSystemOwnerGroup): 

295 @classmethod 

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

297 return "owner" 

298 

299 @classmethod 

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

301 return "passwd.master" 

302 

303 

304class StaticFileSystemGroup(_StaticFileSystemOwnerGroup): 

305 @classmethod 

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

307 return "group" 

308 

309 @classmethod 

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

311 return "group.master" 

312 

313 

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

315class SymlinkTarget: 

316 raw_symlink_target: str 

317 attribute_path: AttributePath 

318 symlink_target: str 

319 

320 @classmethod 

321 def parse_symlink_target( 

322 cls, 

323 raw_symlink_target: str, 

324 attribute_path: AttributePath, 

325 substitution: Substitution, 

326 ) -> "SymlinkTarget": 

327 return SymlinkTarget( 

328 raw_symlink_target, 

329 attribute_path, 

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

331 ) 

332 

333 

334class FileSystemMatchRule: 

335 @property 

336 def raw_match_rule(self) -> str: 

337 raise NotImplementedError 

338 

339 @property 

340 def attribute_path(self) -> AttributePath: 

341 raise NotImplementedError 

342 

343 @property 

344 def match_rule(self) -> MatchRule: 

345 raise NotImplementedError 

346 

347 @classmethod 

348 def parse_path_match( 

349 cls, 

350 raw_match_rule: str, 

351 attribute_path: AttributePath, 

352 parser_context: "ParserContextData", 

353 ) -> "FileSystemMatchRule": 

354 return cls.from_path_match( 

355 raw_match_rule, attribute_path, parser_context.substitution 

356 ) 

357 

358 @classmethod 

359 def from_path_match( 

360 cls, 

361 raw_match_rule: str, 

362 attribute_path: AttributePath, 

363 substitution: "Substitution", 

364 ) -> "FileSystemMatchRule": 

365 try: 

366 mr = MatchRule.from_path_or_glob( 

367 raw_match_rule, 

368 attribute_path.path, 

369 substitution=substitution, 

370 ) 

371 except ValueError as e: 

372 raise ManifestParseException( 

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

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

375 ) 

376 

377 if isinstance(mr, ExactFileSystemPath): 

378 return FileSystemExactMatchRule( 

379 raw_match_rule, 

380 attribute_path, 

381 mr, 

382 ) 

383 return FileSystemGenericMatch( 

384 raw_match_rule, 

385 attribute_path, 

386 mr, 

387 ) 

388 

389 

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

391class FileSystemGenericMatch(FileSystemMatchRule): 

392 raw_match_rule: str 

393 attribute_path: AttributePath 

394 match_rule: MatchRule 

395 

396 

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

398class FileSystemExactMatchRule(FileSystemMatchRule): 

399 raw_match_rule: str 

400 attribute_path: AttributePath 

401 match_rule: ExactFileSystemPath 

402 

403 @classmethod 

404 def from_path_match( 

405 cls, 

406 raw_match_rule: str, 

407 attribute_path: AttributePath, 

408 substitution: "Substitution", 

409 ) -> "FileSystemExactMatchRule": 

410 try: 

411 normalized = _normalize_path(raw_match_rule) 

412 except ValueError as e: 

413 raise ManifestParseException( 

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

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

416 ) from e 

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

418 raise ManifestParseException( 

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

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

421 ) 

422 mr = ExactFileSystemPath( 

423 substitution.substitute(normalized, attribute_path.path) 

424 ) 

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

426 raise ManifestParseException( 

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

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

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

430 " match a *non*-directory" 

431 ) 

432 return cls( 

433 raw_match_rule, 

434 attribute_path, 

435 mr, 

436 ) 

437 

438 

439class FileSystemExactNonDirMatchRule(FileSystemExactMatchRule): 

440 pass