#!/usr/bin/python3 """ Copyright (c) 2022, Ian Santopietro All rights reserved. This file is part of RepoLib. RepoLib is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. RepoLib is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with RepoLib. If not, see . """ import logging from pathlib import Path from debian import deb822 from .parsedeb import ParseDeb from .key import SourceKey from . import util DEFAULT_FORMAT = util.SourceFormat.LEGACY class SourceError(util.RepoError): """ Exception from a source object.""" def __init__(self, *args, code=1, **kwargs): """Exception with a source object Arguments: code (:obj:`int`, optional, default=1): Exception error code. """ super().__init__(*args, **kwargs) self.code = code class Source(deb822.Deb822): """A DEB822 object representing a single software source. Attributes: ident(str): The unique id for this source name(str): The user-readable name for this source enabled(bool): Whether or not the source is enabled types([SourceType]): A list of repository types for this source uris([str]): A list of possible URIs for this source suites([str]): A list of enabled suites for this source components([str]): A list of enabled components for this source comments(str): Comments for this source signed_by(Path): The path to this source's key file file(SourceFile): The file this source belongs to key(SourceKey): The key which signs this source """ default_format = DEFAULT_FORMAT @staticmethod def validator(shortcut:str) -> bool: """Determine whether a deb line is valid. Arguments: shortcut(str): The shortcut to validate Returns: bool `True` if the PPA is valid, otherwise False """ shortcut_list:list = shortcut.split() if not shortcut.startswith('deb'): return False if not len(shortcut_list) > 3: return False if not util.validate_debline: return False if len(shortcut_list) == 3 and '/' not in shortcut_list[-1]: return False return True def __init__(self, *args, file=None, **kwargs) -> None: """Initialize this source object""" self.log = logging.getLogger(__name__) super().__init__(*args, **kwargs) self.reset_values() self.file = file self.twin_source = False self.twin_enabled = False def __repr__(self): """type: () -> str""" # Append comments to the item # if self.options: if self.comments: self['Comments'] = '# ' self['Comments'] += ' # '.join(self.comments) rep:str = '{%s}' % ', '.join(['%r: %r' % (k, v) for k, v in self.items()]) rep:str = '{' for key in self: rep += f"{util.PRETTY_PRINT}'{key}': '{self[key]}', " rep = rep[:-2] rep += f"{util.PRETTY_PRINT.replace(' ', '')}" rep += '}' if self.comments: self.pop('Comments') return rep def __bool__(self) -> bool: has_uri:bool = len(self.uris) > 0 has_suite:bool = len(self.suites) > 0 has_component:bool = len(self.components) > 0 if has_uri and has_suite and has_component: return True return False def get_description(self) -> str: """Get a UI-compatible description for a source. Returns: (str) The formatted description. """ return self.name def reset_values(self) -> None: """Reset the default values for all attributes""" self.log.info('Resetting source info') self.ident = '' self.name = '' self.enabled = True self.types = [util.SourceType.BINARY] self.uris = [] self.suites = [] self.components = [] self.comments = [] self.signed_by = None self.architectures = '' self.languages = '' self.targets = '' self.pdiffs = '' self.by_hash = '' self.allow_insecure = '' self.allow_weak = '' self.allow_downgrade_to_insecure = '' self.trusted = '' self.signed_by = '' self.check_valid_until = '' self.valid_until_min = '' self.valid_until_max = '' self.prefs = '' self._update_legacy_options() self.file = None self.key = None def load_from_data(self, data:list) -> None: """Loads source information from the provided data Should correctly load either a lecagy Deb line (optionally with preceeding comment) or a DEB822 source. Arguments: data(list): the data to load into the source. """ self.log.info('Loading source from data') self.reset_values() if util.validate_debline(data[0]): # Legacy Source if len(data) > 1: raise SourceError( f'The source is a legacy source but contains {len(data)} entries. ' 'It may only contain one entry.' ) deb_parser = ParseDeb() parsed_debline = deb_parser.parse_line(data[0]) self.ident = parsed_debline['ident'] self.name = parsed_debline['name'] self.enabled = parsed_debline['enabled'] self.types = [parsed_debline['repo_type']] self.uris = [parsed_debline['uri']] self.suites = [parsed_debline['suite']] self.components = parsed_debline['components'] for key in parsed_debline['options']: self[key] = parsed_debline['options'][key] self._update_legacy_options() for comment in parsed_debline['comments']: self.comments.append(comment) if self.comments == ['']: self.comments = [] if not self.name: self.name = self.generate_default_name() if self.signed_by: self.load_key() return # DEB822 Source super().__init__(sequence=data) if self.signed_by: self.load_key() return @property def sourcecode_enabled(self) -> bool: """`True` if this source also provides source code, otherwise `False`""" if util.SourceType.SOURCECODE in self.types: return True return False @sourcecode_enabled.setter def sourcecode_enabled(self, enabled) -> None: """Accept a variety of input values""" types = [util.SourceType.BINARY] if enabled in util.true_values: types.append(util.SourceType.SOURCECODE) self.types = types def generate_default_ident(self, prefix='') -> str: """Generates a suitable ID for the source Returns: str A sane default-id """ ident:str = '' if len(self.uris) > 0: uri:str = self.uris[0].replace('/', ' ') uri_list:list = uri.split() uri_str:str = '-'.join(uri_list[1:]) branch_name:str = util.scrub_filename(uri_str) ident = f'{prefix}{branch_name}' ident += f'-{self.types[0].ident()}' try: if not self['X-Repolib-ID']: self['X-Repolib-ID'] = ident except KeyError: self['X-Repolib-ID'] = ident return ident def generate_default_name(self) -> str: """Generate a default name based on the ident Returns: str A name based on the ident """ name:str = self.ident try: name = self['X-Repolib-Name'] except KeyError: self['X-Repolib-Name'] = self.ident return self['X-Repolib-Name'] if not name: self['X-Repolib-Name'] = self.ident return self['X-Repolib-Name'] def load_key(self, ignore_errors:bool = True) -> None: """Finds and loads the signing key from the system Arguments: ignore_errors(bool): If `False`, throw a SourceError exception if the key can't be found/doesn't exist (Default: `True`) """ self.log.info('Finding key for source %s', self.ident) if not self.signed_by: if ignore_errors: self.log.warning('No key configured for source %s', self.ident) else: raise SourceError('No key configured for source {self.ident}') if self.signed_by not in util.keys: new_key = SourceKey() new_key.reset_path(path=self.signed_by) self.key = new_key util.keys[str(new_key.path)] = new_key else: self.key = util.keys[self.signed_by] def save(self) -> None: """Proxy method to save the source""" if not self.file == None: self.file.save() def output_legacy(self) -> str: """Outputs a legacy representation of this source Note: this is expected to fail if there is more than one type, URI, or Suite; the one-line format does not support having multiple of these properties. Returns: str The source output formatted as Legacy """ return self.legacy def output_822(self) -> str: """Outputs a DEB822 representation of this source Returns: str The source output formatted as Deb822 """ return self.deb822 def output_ui(self) -> str: """Outputs a string representation of this source for use in UIs Returns: str The source output string """ return self.ui def prop_append(self, prop:list, item:str) -> None: """Appends an item to a list property of this source. NOTE: List properties are `types`, `uris`, `suites`, and `components`. Arguments: prop(list): The property on which to append the item. item(str): The item to append to the propery """ _list = prop _list.append(item) prop = _list def tasks_save(self, *args, **kwargs) -> None: """Extra tasks to perform when saving a source""" return ## Properties are stored/retrieved from the underlying Deb822 dict @property def has_required_parts(self) -> bool: """(RO) True if all required attributes are set, otherwise false.""" required_parts = ['uris', 'suites', 'ident'] for attr in required_parts: if len(getattr(self, attr)) < 1: return False return True @property def ident(self) -> str: """The ident for this source within the file""" try: return self['X-Repolib-ID'] except KeyError: return '' @ident.setter def ident(self, ident: str) -> None: ident = util.scrub_filename(ident) self['X-Repolib-ID'] = ident @property def name(self) -> str: """The human-friendly name for this source""" try: _name = self['X-Repolib-Name'] except KeyError: _name = '' if not _name: self.generate_default_name() return self['X-Repolib-Name'] @name.setter def name(self, name: str) -> None: self['X-Repolib-Name'] = name @property def enabled(self) -> util.AptSourceEnabled: """Whether or not the source is enabled/active""" try: enabled = self['Enabled'] in util.true_values except KeyError: return util.AptSourceEnabled.FALSE if enabled and self.has_required_parts: return util.AptSourceEnabled.TRUE return util.AptSourceEnabled.FALSE @enabled.setter def enabled(self, enabled) -> None: """For convenience, accept a wide varietry of input value types""" self['Enabled'] = 'no' if enabled in util.true_values: self['Enabled'] = 'yes' @property def types(self) -> list: """The list of source types for this source""" _types:list = [] try: for sourcetype in self['types'].split(): _types.append(util.SourceType(sourcetype)) except KeyError: pass return _types @types.setter def types(self, types: list) -> None: """Turn this list into a string of values for storage""" self['Types'] = '' _types:list = [] for sourcetype in types: if sourcetype not in _types: _types.append(sourcetype) for sourcetype in _types: self['Types'] += f'{sourcetype.value} ' self['Types'] = self['Types'].strip() @property def uris(self) -> list: """The list of URIs for this source""" try: return self['URIs'].split() except KeyError: return [] @uris.setter def uris(self, uris: list) -> None: self['URIs'] = ' '.join(uris).strip() @property def suites(self) -> list: """The list of URIs for this source""" try: return self['Suites'].split() except KeyError: return [] @suites.setter def suites(self, suites: list) -> None: self['Suites'] = ' '.join(suites).strip() @property def components(self) -> list: """The list of URIs for this source""" try: return self['Components'].split() except KeyError: return [] @components.setter def components(self, components: list) -> None: self['Components'] = ' '.join(components).strip() @property def options(self) -> dict: """The options for this source""" return self._options @options.setter def options(self, options:dict) -> None: if 'Signed-By' in options: self.signed_by = options['Signed-By'] if self.signed_by: options.pop('Signed-By') self._options = options @property def prefs(self): """The path to any apt preferences files for this source.""" try: prefs = self['X-Repolib-Prefs'] except KeyError: prefs = '' if prefs: return Path(prefs) return Path() @prefs.setter def prefs(self, prefs): """Accept a str or a Path-like object""" try: del self['X-Repolib-Prefs'] except KeyError: pass if prefs: prefs_str = str(prefs) self['X-Repolib-Prefs'] = prefs_str ## Option properties @property def architectures (self) -> str: """architectures option""" try: return self['Architectures'] except KeyError: return '' @architectures.setter def architectures(self, data) -> None: try: self.pop('Architectures') except KeyError: pass if data: self['Architectures'] = data self._update_legacy_options() @property def languages (self) -> str: """languages option""" try: return self['Languages'] except KeyError: return '' @languages.setter def languages(self, data) -> None: try: self.pop('Languages') except KeyError: pass if data: self['Languages'] = data self._update_legacy_options() @property def targets (self) -> str: """targets option""" try: return self['Targets'] except KeyError: return '' @targets.setter def targets(self, data) -> None: try: self.pop('Targets') except KeyError: pass if data: self['Targets'] = data self._update_legacy_options() @property def pdiffs (self) -> str: """pdiffs option""" try: return self['Pdiffs'] except KeyError: return '' @pdiffs.setter def pdiffs(self, data) -> None: try: self.pop('Pdiffs') except KeyError: pass if data: self['Pdiffs'] = data self._update_legacy_options() @property def by_hash (self) -> str: """by_hash option""" try: return self['By-Hash'] except KeyError: return '' @by_hash.setter def by_hash(self, data) -> None: try: self.pop('By-Hash') except KeyError: pass if data: self['By-Hash'] = data self._update_legacy_options() @property def allow_insecure (self) -> str: """allow_insecure option""" try: return self['Allow-Insecure'] except KeyError: return '' @allow_insecure.setter def allow_insecure(self, data) -> None: try: self.pop('Allow-Insecure') except KeyError: pass if data: self['Allow-Insecure'] = data self._update_legacy_options() @property def allow_weak (self) -> str: """allow_weak option""" try: return self['Allow-Weak'] except KeyError: return '' @allow_weak.setter def allow_weak(self, data) -> None: try: self.pop('Allow-Weak') except KeyError: pass if data: self['Allow-Weak'] = data self._update_legacy_options() @property def allow_downgrade_to_insecure (self) -> str: """allow_downgrade_to_insecure option""" try: return self['Allow-Downgrade-To-Insecure'] except KeyError: return '' @allow_downgrade_to_insecure.setter def allow_downgrade_to_insecure(self, data) -> None: try: self.pop('Allow-Downgrade-To-Insecure') except KeyError: pass if data: self['Allow-Downgrade-To-Insecure'] = data self._update_legacy_options() @property def trusted (self) -> str: """trusted option""" try: return self['Trusted'] except KeyError: return '' @trusted.setter def trusted(self, data) -> None: try: self.pop('Trusted') except KeyError: pass if data: self['Trusted'] = data self._update_legacy_options() @property def signed_by (self) -> str: """signed_by option""" try: return self['Signed-By'] except KeyError: return '' @signed_by.setter def signed_by(self, data) -> None: try: self.pop('Signed-By') except KeyError: pass if data: self['Signed-By'] = data self._update_legacy_options() @property def check_valid_until (self) -> str: """check_valid_until option""" try: return self['Check-Valid-Until'] except KeyError: return '' @check_valid_until.setter def check_valid_until(self, data) -> None: try: self.pop('Check-Valid-Until') except KeyError: pass if data: self['Check-Valid-Until'] = data self._update_legacy_options() @property def valid_until_min (self) -> str: """valid_until_min option""" try: return self['Valid-Until-Min'] except KeyError: return '' @valid_until_min.setter def valid_until_min(self, data) -> None: try: self.pop('Valid-Until-Min') except KeyError: pass if data: self['Valid-Until-Min'] = data self._update_legacy_options() @property def valid_until_max (self) -> str: """valid_until_max option""" try: return self['Valid-Until-Max'] except KeyError: return '' @valid_until_max.setter def valid_until_max(self, data) -> None: try: self.pop('Valid-Until-Max') except KeyError: pass if data: self['Valid-Until-Max'] = data self._update_legacy_options() @property def default_mirror(self) -> str: """The default mirror/URI for the source""" try: return self['X-Repolib-Default-Mirror'] except KeyError: return '' @default_mirror.setter def default_mirror(self, mirror) -> None: if mirror: self['X-Repolib-Default-Mirror'] = mirror else: self['X-Repolib-Default-Mirror'] = '' ## Output Properties @property def deb822(self) -> str: """The DEB822 representation of this source""" self._update_legacy_options() # comments get handled separately because they're a list, and list # properties don't support .append() if self.comments: self['X-Repolib-Comments'] = '# ' self['X-Repolib-Comments'] += ' # '.join(self.comments) _deb822 = self.dump() if self.comments: self.pop('X-Repolib-Comments') if _deb822: return _deb822 return '' @property def ui(self) -> str: """The UI-friendly representation of this source""" self._update_legacy_options() _ui_list:list = self.deb822.split('\n') ui_output: str = f'{self.ident}:\n' for line in _ui_list: key = line.split(':')[0] if key not in util.output_skip_keys: if line: ui_output += f'{line}\n' for key in util.keys_map: ui_output = ui_output.replace(key, util.keys_map[key]) return ui_output @property def legacy(self) -> str: """The legacy/one-line format representation of this source""" self._update_legacy_options() if str(self.prefs) != '.': raise SourceError( 'Apt Preferences files can only be used with DEB822-format sources.' ) sourcecode = self.sourcecode_enabled if len(self.types) > 1: self.twin_source = True self.types = [util.SourceType.BINARY] sourcecode = True legacy = '' legacy += self._generate_legacy_output() if self.twin_source: legacy += '\n' legacy += self._generate_legacy_output(sourcecode=True, enabled=sourcecode) return legacy def _generate_legacy_output(self, sourcecode=False, enabled=True) -> str: """Generate a string of the current source in legacy format""" legacy = '' if len(self.types) > 1: self.twin_source = True self.types = [util.SourceType.BINARY] for attr in ['types', 'uris', 'suites']: if len(getattr(self, attr)) > 1: msg = f'The source has too many {attr}.' msg += f'Legacy-format sources support one {attr[:-1]} only.' raise SourceError(msg) if not self.enabled.get_bool() and not sourcecode: legacy += '# ' if sourcecode and not enabled: legacy += '# ' if sourcecode: legacy += 'deb-src ' else: legacy += self.types[0].value legacy += ' ' options_string = self._legacy_options() if options_string: legacy += '[' legacy += options_string legacy = legacy.strip() legacy += '] ' legacy += f'{self.uris[0]} ' legacy += f'{self.suites[0]} ' for component in self.components: legacy += f'{component} ' legacy += f' ## X-Repolib-Name: {self.name}' legacy += f' # X-Repolib-ID: {self.ident}' if self.comments: for comment in self.comments: legacy += f' # {comment}' return legacy def _legacy_options(self) -> str: """Turn the current options into a oneline-style string Returns: str The one-line-format options string """ options_str = '' for key in self.options: if self.options[key] != '': options_str += f'{key}={self.options[key].replace(" ", ",")} ' return options_str def _update_legacy_options(self) -> None: """Updates the current set of legacy options""" self.options = { 'arch': self.architectures, 'lang': self.languages, 'target': self.targets, 'pdiffs': self.pdiffs, 'by-hash': self.by_hash, 'allow-insecure': self.allow_insecure, 'allow-weak': self.allow_weak, 'allow-downgrade-to-insecure': self.allow_downgrade_to_insecure, 'trusted': self.trusted, 'signed-by': self.signed_by, 'check-valid-until': self.check_valid_until, 'valid-until-min': self.valid_until_min, 'valid-until-max': self.valid_until_max, }