diff options
Diffstat (limited to 'src/debputy/manifest_parser/base_types.py')
-rw-r--r-- | src/debputy/manifest_parser/base_types.py | 440 |
1 files changed, 440 insertions, 0 deletions
diff --git a/src/debputy/manifest_parser/base_types.py b/src/debputy/manifest_parser/base_types.py new file mode 100644 index 0000000..865e320 --- /dev/null +++ b/src/debputy/manifest_parser/base_types.py @@ -0,0 +1,440 @@ +import dataclasses +import os +from functools import lru_cache +from typing import ( + TypedDict, + NotRequired, + Sequence, + Optional, + Union, + Literal, + Tuple, + Mapping, + Iterable, + TYPE_CHECKING, + Callable, + Type, + Generic, +) + +from debputy.manifest_parser.exceptions import ManifestParseException +from debputy.manifest_parser.util import ( + AttributePath, + _SymbolicModeSegment, + parse_symbolic_mode, +) +from debputy.path_matcher import MatchRule, ExactFileSystemPath +from debputy.substitution import Substitution +from debputy.types import S +from debputy.util import _normalize_path, T + +if TYPE_CHECKING: + from debputy.manifest_conditions import ManifestCondition + from debputy.manifest_parser.parser_data import ParserContextData + + +class DebputyParsedContent(TypedDict): + pass + + +class DebputyDispatchableType: + __slots__ = () + + +class DebputyParsedContentStandardConditional(DebputyParsedContent): + when: NotRequired["ManifestCondition"] + + +@dataclasses.dataclass(slots=True, frozen=True) +class OwnershipDefinition: + entity_name: str + entity_id: int + + +@dataclasses.dataclass +class TypeMapping(Generic[S, T]): + target_type: Type[T] + source_type: Type[S] + mapper: Callable[[S, AttributePath, Optional["ParserContextData"]], T] + + +ROOT_DEFINITION = OwnershipDefinition("root", 0) + + +BAD_OWNER_NAMES = { + "_apt", # All things owned by _apt are generated by apt after installation + "nogroup", # It is not supposed to own anything as it is an entity used for dropping permissions + "nobody", # It is not supposed to own anything as it is an entity used for dropping permissions +} +BAD_OWNER_IDS = { + 65534, # ID of nobody / nogroup +} + + +def _parse_ownership( + v: Union[str, int], + attribute_path: AttributePath, +) -> Tuple[Optional[str], Optional[int]]: + if isinstance(v, str) and ":" in v: + if v == ":": + raise ManifestParseException( + f'Invalid ownership value "{v}" at {attribute_path.path}: Ownership is redundant if it is ":"' + f" (blank name and blank id). Please provide non-default values or remove the definition." + ) + entity_name: Optional[str] + entity_id: Optional[int] + entity_name, entity_id_str = v.split(":") + if entity_name == "": + entity_name = None + if entity_id_str != "": + entity_id = int(entity_id_str) + else: + entity_id = None + return entity_name, entity_id + + if isinstance(v, int): + return None, v + if v.isdigit(): + raise ManifestParseException( + f'Invalid ownership value "{v}" at {attribute_path.path}: The provided value "{v}" is a string (implying' + " name lookup), but it contains an integer (implying id lookup). Please use a regular int for id lookup" + f' (removing the quotes) or add a ":" in the end ("{v}:") as a disambiguation if you are *really* looking' + " for an entity with that name." + ) + return v, None + + +@lru_cache +def _load_ownership_table_from_file( + name: Literal["passwd.master", "group.master"], +) -> Tuple[Mapping[str, OwnershipDefinition], Mapping[int, OwnershipDefinition]]: + filename = os.path.join("/usr/share/base-passwd", name) + name_table = {} + uid_table = {} + for owner_def in _read_ownership_def_from_base_password_template(filename): + # Could happen if base-passwd template has two users with the same ID. We assume this will not occur. + assert owner_def.entity_name not in name_table + assert owner_def.entity_id not in uid_table + name_table[owner_def.entity_name] = owner_def + uid_table[owner_def.entity_id] = owner_def + + return name_table, uid_table + + +def _read_ownership_def_from_base_password_template( + template_file: str, +) -> Iterable[OwnershipDefinition]: + with open(template_file) as fd: + for line in fd: + entity_name, _star, entity_id, _remainder = line.split(":", 3) + if entity_id == "0" and entity_name == "root": + yield ROOT_DEFINITION + else: + yield OwnershipDefinition(entity_name, int(entity_id)) + + +class FileSystemMode: + @classmethod + def parse_filesystem_mode( + cls, + mode_raw: str, + attribute_path: AttributePath, + ) -> "FileSystemMode": + if mode_raw and mode_raw[0].isdigit(): + return OctalMode.parse_filesystem_mode(mode_raw, attribute_path) + return SymbolicMode.parse_filesystem_mode(mode_raw, attribute_path) + + def compute_mode(self, current_mode: int, is_dir: bool) -> int: + raise NotImplementedError + + +@dataclasses.dataclass(slots=True, frozen=True) +class SymbolicMode(FileSystemMode): + provided_mode: str + segments: Sequence[_SymbolicModeSegment] + + @classmethod + def parse_filesystem_mode( + cls, + mode_raw: str, + attribute_path: AttributePath, + ) -> "SymbolicMode": + segments = list(parse_symbolic_mode(mode_raw, attribute_path)) + return SymbolicMode(mode_raw, segments) + + def __str__(self) -> str: + return self.symbolic_mode() + + @property + def is_symbolic_mode(self) -> bool: + return False + + def symbolic_mode(self) -> str: + return self.provided_mode + + def compute_mode(self, current_mode: int, is_dir: bool) -> int: + final_mode = current_mode + for segment in self.segments: + final_mode = segment.apply(final_mode, is_dir) + return final_mode + + +@dataclasses.dataclass(slots=True, frozen=True) +class OctalMode(FileSystemMode): + octal_mode: int + + @classmethod + def parse_filesystem_mode( + cls, + mode_raw: str, + attribute_path: AttributePath, + ) -> "FileSystemMode": + try: + mode = int(mode_raw, base=8) + except ValueError as e: + error_msg = 'An octal mode must be all digits between 0-7 (such as "644")' + raise ManifestParseException( + f"Cannot parse {attribute_path.path} as an octal mode: {error_msg}" + ) from e + return OctalMode(mode) + + @property + def is_octal_mode(self) -> bool: + return True + + def compute_mode(self, _current_mode: int, _is_dir: bool) -> int: + return self.octal_mode + + def __str__(self) -> str: + return f"0{oct(self.octal_mode)[2:]}" + + +@dataclasses.dataclass(slots=True, frozen=True) +class _StaticFileSystemOwnerGroup: + ownership_definition: OwnershipDefinition + + @property + def entity_name(self) -> str: + return self.ownership_definition.entity_name + + @property + def entity_id(self) -> int: + return self.ownership_definition.entity_id + + @classmethod + def from_manifest_value( + cls, + raw_input: Union[str, int], + attribute_path: AttributePath, + ) -> "_StaticFileSystemOwnerGroup": + provided_name, provided_id = _parse_ownership(raw_input, attribute_path) + owner_def = cls._resolve(raw_input, provided_name, provided_id, attribute_path) + if ( + owner_def.entity_name in BAD_OWNER_NAMES + or owner_def.entity_id in BAD_OWNER_IDS + ): + raise ManifestParseException( + f'Refusing to use "{raw_input}" as {cls._owner_type()} (defined at {attribute_path.path})' + f' as it resolves to "{owner_def.entity_name}:{owner_def.entity_id}" and no path should have this' + f" entity as {cls._owner_type()} as it is unsafe." + ) + return cls(owner_def) + + @classmethod + def _resolve( + cls, + raw_input: Union[str, int], + provided_name: Optional[str], + provided_id: Optional[int], + attribute_path: AttributePath, + ) -> OwnershipDefinition: + table_name = cls._ownership_table_name() + name_table, id_table = _load_ownership_table_from_file(table_name) + name_match = ( + name_table.get(provided_name) if provided_name is not None else None + ) + id_match = id_table.get(provided_id) if provided_id is not None else None + if id_match is None and name_match is None: + name_part = provided_name if provided_name is not None else "N/A" + id_part = provided_id if provided_id is not None else "N/A" + raise ManifestParseException( + f'Cannot resolve "{raw_input}" as {cls._owner_type()} (from {attribute_path.path}):' + f" It is not known to be a static {cls._owner_type()} from base-passwd." + f' The value was interpreted as name: "{name_part}" and id: {id_part}' + ) + if id_match is None: + assert name_match is not None + return name_match + if name_match is None: + assert id_match is not None + return id_match + if provided_name != id_match.entity_name: + raise ManifestParseException( + f"Bad {cls._owner_type()} declaration: The id {provided_id} resolves to {id_match.entity_name}" + f" according to base-passwd, but the packager declared to should have been {provided_name}" + f" at {attribute_path.path}" + ) + if provided_id != name_match.entity_id: + raise ManifestParseException( + f"Bad {cls._owner_type} declaration: The name {provided_name} resolves to {name_match.entity_id}" + f" according to base-passwd, but the packager declared to should have been {provided_id}" + f" at {attribute_path.path}" + ) + return id_match + + @classmethod + def _owner_type(cls) -> Literal["owner", "group"]: + raise NotImplementedError + + @classmethod + def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]: + raise NotImplementedError + + +class StaticFileSystemOwner(_StaticFileSystemOwnerGroup): + @classmethod + def _owner_type(cls) -> Literal["owner", "group"]: + return "owner" + + @classmethod + def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]: + return "passwd.master" + + +class StaticFileSystemGroup(_StaticFileSystemOwnerGroup): + @classmethod + def _owner_type(cls) -> Literal["owner", "group"]: + return "group" + + @classmethod + def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]: + return "group.master" + + +@dataclasses.dataclass(slots=True, frozen=True) +class SymlinkTarget: + raw_symlink_target: str + attribute_path: AttributePath + symlink_target: str + + @classmethod + def parse_symlink_target( + cls, + raw_symlink_target: str, + attribute_path: AttributePath, + substitution: Substitution, + ) -> "SymlinkTarget": + return SymlinkTarget( + raw_symlink_target, + attribute_path, + substitution.substitute(raw_symlink_target, attribute_path.path), + ) + + +class FileSystemMatchRule: + @property + def raw_match_rule(self) -> str: + raise NotImplementedError + + @property + def attribute_path(self) -> AttributePath: + raise NotImplementedError + + @property + def match_rule(self) -> MatchRule: + raise NotImplementedError + + @classmethod + def parse_path_match( + cls, + raw_match_rule: str, + attribute_path: AttributePath, + parser_context: "ParserContextData", + ) -> "FileSystemMatchRule": + return cls.from_path_match( + raw_match_rule, attribute_path, parser_context.substitution + ) + + @classmethod + def from_path_match( + cls, + raw_match_rule: str, + attribute_path: AttributePath, + substitution: "Substitution", + ) -> "FileSystemMatchRule": + try: + mr = MatchRule.from_path_or_glob( + raw_match_rule, + attribute_path.path, + substitution=substitution, + ) + except ValueError as e: + raise ManifestParseException( + f'Could not parse "{raw_match_rule}" (defined at {attribute_path.path})' + f" as a path or a glob: {e.args[0]}" + ) + + if isinstance(mr, ExactFileSystemPath): + return FileSystemExactMatchRule( + raw_match_rule, + attribute_path, + mr, + ) + return FileSystemGenericMatch( + raw_match_rule, + attribute_path, + mr, + ) + + +@dataclasses.dataclass(slots=True, frozen=True) +class FileSystemGenericMatch(FileSystemMatchRule): + raw_match_rule: str + attribute_path: AttributePath + match_rule: MatchRule + + +@dataclasses.dataclass(slots=True, frozen=True) +class FileSystemExactMatchRule(FileSystemMatchRule): + raw_match_rule: str + attribute_path: AttributePath + match_rule: ExactFileSystemPath + + @classmethod + def from_path_match( + cls, + raw_match_rule: str, + attribute_path: AttributePath, + substitution: "Substitution", + ) -> "FileSystemExactMatchRule": + try: + normalized = _normalize_path(raw_match_rule) + except ValueError as e: + raise ManifestParseException( + f'The path "{raw_match_rule}" provided in {attribute_path.path} should be relative to the' + ' root of the package and not use any ".." or "." segments.' + ) from e + if normalized == ".": + raise ManifestParseException( + f'The path "{raw_match_rule}" matches a file system root and that is not a valid match' + f' at "{attribute_path.path}". Please narrow the provided path.' + ) + mr = ExactFileSystemPath( + substitution.substitute(normalized, attribute_path.path) + ) + if mr.path.endswith("/") and issubclass(cls, FileSystemExactNonDirMatchRule): + raise ManifestParseException( + f'The path "{raw_match_rule}" at {attribute_path.path} resolved to' + f' "{mr.path}". Since the resolved path ends with a slash ("/"), this' + " means only a directory can match. However, this attribute should" + " match a *non*-directory" + ) + return cls( + raw_match_rule, + attribute_path, + mr, + ) + + +class FileSystemExactNonDirMatchRule(FileSystemExactMatchRule): + pass |