summaryrefslogtreecommitdiffstats
path: root/src/debputy/manifest_parser/base_types.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/debputy/manifest_parser/base_types.py')
-rw-r--r--src/debputy/manifest_parser/base_types.py440
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