| """Utilities to access an Android tree manifest data.""" |
| |
| from pathlib import Path, PurePath |
| import re |
| import xml.dom.minidom |
| |
| |
| class ProjectNotFoundError(Exception): |
| """When a project is not found. |
| |
| Attributes: |
| project: The project name. |
| |
| """ |
| |
| def __init__(self, project): |
| self.project = project |
| super().__init__(f"Project not found: {project}") |
| |
| |
| class AndroidManifest: |
| """Wrapper around an Android tree manifest. |
| |
| This class extracts projects of an Android manifest XML; it parses manifest include tags as well |
| as project removal tags in line with the logic of the `repo` tool. |
| |
| Attributes: |
| projects: The mapping of AOSP project names to the checked-out project |
| path. |
| |
| """ |
| |
| TAG_MANIFEST = "manifest" |
| TAG_PROJECT = "project" |
| TAG_INCLUDE_MANIFEST = "include" |
| TAG_REMOVE_PROJECT = "remove-project" |
| IGNORED_TAGS = [ |
| "#comment", |
| "#text", |
| "default", |
| "remote", |
| ] |
| |
| #: Match the AOSP project name in a Fairphone manifest (special prefixes) |
| _AOSP_NAME_IN_FP_MANIFEST = re.compile(r"^((fp2|fp2-dev)/)?(?P<name>(kernel|platform)/.*)$") |
| |
| def __init__(self, manifests_root_path: Path, relative_manifest_path: PurePath): |
| """Create an instance from an Android tree XML file.""" |
| self._manifests_root_path = manifests_root_path |
| self.projects = {} |
| self._resolve_manifest_list_projects(relative_manifest_path) |
| |
| # Add self-reference to the manifest project, as done in AOSP. We usually don't have these |
| # entries in Fairphone manifests, but they are relevant when looking for manifest patches. |
| # Add both AOSP and Fairphone manifest project name for convenience. |
| self.projects["manifest"] = ".repo/manifests" |
| self.projects["platform/manifest"] = ".repo/manifests" |
| |
| @classmethod |
| def _load_manifest_file(cls, manifest_file_path: Path): |
| """Load a XML manifest. |
| |
| Arguments: |
| manifest_file_path: Path of the manifest XML file to load. |
| Returns: |
| The first XML node describing the manifest content. |
| Raises: |
| ValueError: If the manifest is a malformed XML file or if the |
| manifest node is missing. |
| |
| """ |
| try: |
| root = xml.dom.minidom.parse(str(manifest_file_path)) |
| except (OSError, xml.parsers.expat.ExpatError) as exception: |
| raise ValueError( |
| f"Error parsing manifest {manifest_file_path}: {exception}" |
| ) from exception |
| |
| if not root or not root.childNodes: |
| raise ValueError(f"No root node in {manifest_file_path}") |
| |
| manifest_node = next((n for n in root.childNodes if n.nodeName == cls.TAG_MANIFEST), None) |
| if not manifest_node: |
| raise ValueError(f"No <{cls.TAG_MANIFEST}> node in {manifest_file_path}") |
| |
| return manifest_node |
| |
| def _resolve_manifest_list_projects(self, relative_manifest_path: PurePath): |
| """Resolve effective project list starting from a (sub-)manifest. |
| |
| Update self's effective project list based on a the contents of |
| the manifest at relative_manifest_path. Include sub-manifests |
| recursively, apply project removals. |
| |
| Arguments: |
| relative_manifest_path: Path of the manifest to parse relative to |
| self's initial root path. |
| """ |
| manifest_node = self._load_manifest_file(self._manifests_root_path / relative_manifest_path) |
| for node in manifest_node.childNodes: |
| if node.nodeName == self.TAG_PROJECT: |
| self._add_project(node) |
| elif node.nodeName == self.TAG_INCLUDE_MANIFEST: |
| relative_include_path = node.getAttribute("name") |
| # Process included manifest and extract its projects as well. Don't bother |
| # actually merging its structure into the current XML; we don't need that merged |
| # XML tree, only the effective projects. |
| self._resolve_manifest_list_projects(relative_include_path) |
| elif node.nodeName == self.TAG_REMOVE_PROJECT: |
| self._remove_project(node) |
| elif node.nodeName not in self.IGNORED_TAGS: |
| raise ValueError(f"Can't parse manifest tag: {node.nodeName}") |
| |
| def _add_project(self, project_node): |
| """Extract and store relevant properties of a project. |
| |
| This also handles Fairphone-specific differences compared to AOSP manifests: |
| * Legacy project names are prefixed with "fp2/": "fp2/kernel/*", "fp2/platform/*", |
| "fp2/vendor/*". Vendor projects are, by definition, not going to be advertised in the |
| original Android manifest. Kernel and platform projects are in the AOSP manifests and can |
| thus be targeted by security bulletins. The Fairphone prefix is removed to make the |
| projects names match those from the AOSP manifest. |
| * Legacy Fairphone Sibon manifests are using "fp2-dev/" in place of "fp2/". They are also |
| handled and removed here. |
| |
| Arguments: |
| project_node: XML <project> node from a manifest file. |
| Raises: |
| ValueError: If the project node is invalid or if the project (same name) was already |
| added. |
| """ |
| name = self._get_project_name(project_node) |
| if name in self.projects: |
| raise ValueError(f"Duplicate project {name} in manifest") |
| |
| path = project_node.getAttribute("path") or name |
| self.projects[name] = path |
| |
| def _remove_project(self, project_node): |
| """Drop a project according to a 'remove-project' node. |
| |
| Follow the logic of the 'repo' tool to identify and drop a project from a manifest project |
| list. |
| |
| Arguments: |
| project_node: XML <project> node from a manifest file. |
| Raises: |
| ValueError: If the project node is invalid or if the project to remove wasn't added |
| before. |
| """ |
| name = self._get_project_name(project_node) |
| try: |
| del self.projects[name] |
| except KeyError as e: |
| raise ValueError( |
| f"Project {name} requested to be removed but wasn't added before." |
| ) from e |
| |
| @classmethod |
| def _get_project_name(cls, node): |
| """Extract the AOSP project name from an XML node. |
| |
| Arguments: |
| node: The project XML node. |
| Returns: |
| The project name cleared of Fairphone-specific prefixes. |
| |
| """ |
| name = node.getAttribute("name") |
| |
| # Fix Fairphone-specific names |
| match = cls._AOSP_NAME_IN_FP_MANIFEST.match(name) |
| if match: |
| name = match.group("name") |
| |
| return name |