diff --git a/debian/rules b/debian/rules index af76d81..faedc5c 100755 --- a/debian/rules +++ b/debian/rules @@ -1,14 +1,16 @@ #!/usr/bin/make -f + +### WTF ### # See debhelper(7) (uncomment to enable) # output every command that modifies files on the build system. #export DH_VERBOSE = 1 - -export PYBUILD_NAME=repolib -export PYBUILD_OPTION=--test-pytest - -%: - dh $@ --with python3 --buildsystem=pybuild - +# +#export PYBUILD_NAME=repolib +#export PYBUILD_OPTION=--test-pytest +# +#%: +# dh $@ --with python3 --buildsystem=pybuild +# ## Uncomment to disable testing during package builds ## NOTE for QA or Engineering Review: This should not be uncommented in a ## PR. If it is, DO NOT APPROVE THE PR!!! @@ -21,4 +23,7 @@ export PYBUILD_OPTION=--test-pytest # dh_auto_build # PYTHONPATH=. http_proxy='127.0.0.1:9' sphinx-build -N -bhtml docs/ build/html # HTML generator # PYTHONPATH=. http_proxy='127.0.0.1:9' sphinx-build -N -bman docs/ build/man # Manpage generator +### WTF ### +%: + dh $@ diff --git a/main.sh b/main.sh index 90c535f..5e24bad 100755 --- a/main.sh +++ b/main.sh @@ -4,16 +4,19 @@ add-apt-repository https://ppa.pika-os.com add-apt-repository ppa:pikaos/pika add-apt-repository ppa:kubuntu-ppa/backports # Clone Upstream -git clone https://github.com/pop-os/repolib -rm -rvf ./repolib/debian -cp -rvf ./debian ./repolib +### WTF ### +#git clone https://github.com/pop-os/repolib +#rm -rvf ./repolib/debian +### WTF ### +cp -rvf ./python3-repolib.install ./debian/ +cp -rvf ./debian ./repolib/ cd ./repolib # Get build deps apt-get build-dep ./ -y # Build package -dpkg-buildpackage +dpkg-buildpackage --no-sign # Move the debs to output cd ../ diff --git a/python3-repolib.install b/python3-repolib.install new file mode 100644 index 0000000..197b8cc --- /dev/null +++ b/python3-repolib.install @@ -0,0 +1,2 @@ +usr +etc diff --git a/repolib/etc/dbus-1/system.d/org.pop_os.repolib.conf b/repolib/etc/dbus-1/system.d/org.pop_os.repolib.conf new file mode 100644 index 0000000..cadef8b --- /dev/null +++ b/repolib/etc/dbus-1/system.d/org.pop_os.repolib.conf @@ -0,0 +1,20 @@ + + + + system + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/repolib/usr/bin/apt-manage b/repolib/usr/bin/apt-manage new file mode 100755 index 0000000..77ae812 --- /dev/null +++ b/repolib/usr/bin/apt-manage @@ -0,0 +1,85 @@ +#!/usr/bin/python3 + +""" +Copyright (c) 2019-2020, 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 . +""" +#pylint: disable=invalid-name +# Pylint will complain about our module name not being snake_case, however this +# is a command rather than a python module, and thus this is correct anyway. + +import argparse +import logging +import os +import sys + +import repolib +from repolib import command + +SOURCES_DIR = '/etc/apt/sources.list.d' + +def main(options=None): + """ Main function for apt-manage.""" + # Set up Argument Parsing. + parser = repolib.command.parser + + # Parse options + args = parser.parse_args() + if options: + args = parser.parse_args(options) + + if not args.debug: + args.debug = 0 + + if args.debug > 2: + args.debug = 2 + + verbosity = { + 0 : logging.WARN, + 1 : logging.INFO, + 2 : logging.DEBUG + } + + log = logging.getLogger('apt-manage') + handler = logging.StreamHandler() + formatter = logging.Formatter('%(name)s: %(levelname)s: %(message)s') + handler.setFormatter(formatter) + log.addHandler(handler) + log.setLevel(verbosity[args.debug]) + log.debug('Logging set up!') + repolib.set_logging_level(args.debug) + + if not args.action: + args = parser.parse_args(sys.argv[1:] + ['list']) + + log.debug('Arguments passed: %s', str(args)) + log.debug('Got command: %s', args.action) + + subcommand = args.action.capitalize() + + command = getattr(repolib.command, subcommand)(log, args, parser) + result = command.run() + if not result: + sys.exit(1) + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + print('') + sys.exit(130) diff --git a/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/PKG-INFO b/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/PKG-INFO new file mode 100644 index 0000000..d183cd9 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/PKG-INFO @@ -0,0 +1,182 @@ +Metadata-Version: 2.1 +Name: repolib +Version: 2.2.0 +Summary: Easily manage software sources +Home-page: https://github.com/pop-os/repolib +Author: Ian Santopietro +Author-email: ian@system76.com +License: LGPLv3 +Download-URL: https://github.com/pop-os/repolib/releases +Platform: UNKNOWN +License-File: LICENSE +License-File: LICENSE.LESSER + +======= +RepoLib +======= + +RepoLib is a Python library and CLI tool-set for managing your software +system software repositories. It's currently set up to handle APT repositories +on Debian-based linux distributions. + +RepoLib is intended to operate on DEB822-format sources. It aims to provide +feature parity with software-properties for most commonly used functions. + +Documentation +============= + +Documentation is available online at `Read The Docs `_. + + +Basic CLI Usage +--------------- + +RepoLib includes a CLI program for managing software repositories, +:code:`apt-manage` +. + +Usage is divided into subcommands for most tasks. Currently implemented commands +are: + + apt-manage add # Adds repositories to the system + apt-manage list # Lists configuration details of repositories + +Additional information is available with the built-in help: + + apt-manage --help + + +Add +^^^ + +Apt-manage allows entering a URL for a repository, a complete debian line, or a +Launchpad PPA shortcut (e.g. "ppa:user/repo"). It also adds signing keys for PPA +style repositories automatically. + + +List +^^^^ + +With no options, it outputs a list of the currently configured repositories on +the system (all those found in +:code:`/etc/apt/sources.list.d/` +. With a configured repository as an argument, it outputs the configuration +details of the specified repository. + +Remove +^^^^^^ + +Accepts one repository as an argument. Removes the specified repository. + +NOTE: The system repository (/etc/at/sources.list.d/system.sources) cannot be +removed. + +Source +^^^^^^ + +Allows enabling or disabling source code for the given repository. + +Modify +^^^^^^ + +Allows changing configuration details of a given repository + +Installation +============ + +From System Package Manager +--------------------------- + +If your operating system packages repolib, you can install it by running:: + + sudo apt install python3-repolib + + +Uninstall +^^^^^^^^^ + +To uninstall, simply do:: + + sudo apt remove python3-repolib + + +From PyPI +--------- + +Repolib is available on PyPI. You can install it for your current user with:: + + pip3 install repolib + +Alternatively, you can install it system-wide using:: + + sudo pip3 install repolib + +Uninstall +^^^^^^^^^ + +To uninstall, simply do:: + + sudo pip3 uninstall repolib + +From Git +-------- + +First, clone the git repository onto your local system:: + + git clone https://github.com/isantop/repolib + cd repolib + +Debian +------ + +On debian based distributions, you can build a .deb package locally and install +it onto your system. You will need the following build-dependencies: + + * debhelper (>=11) + * dh-python + * python3-all + * python3-setuptools + +You can use this command to install these all in one go:: + + sudo apt install debhelper dh-python python3-all python3-setuptools + +Then build and install the package:: + + debuild -us -uc + cd .. + sudo dpkg -i python3-repolib_*.deb + +Uninstall +^^^^^^^^^ + +To uninstall, simply do:: + + sudo apt remove python3-repolib + +setuptools setup.py +------------------- + +You can build and install the package using python3-setuptools. First, install +the dependencies:: + + sudo apt install python3-all python3-setuptools + +Then build and install the package:: + + sudo python3 ./setup.py install + +Uninstall +^^^^^^^^^ + +You can uninstall RepoLib by removing the following files/directories: + + * /usr/local/lib/python3.7/dist-packages/repolib/ + * /usr/local/lib/python3.7/dist-packages/repolib-\*.egg-info + * /usr/local/bin/apt-manage + +This command will remove all of these for you:: + + sudo rm -r /usr/local/lib/python3.7/dist-packages/repolib* /usr/local/bin/apt-manage + + diff --git a/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/dependency_links.txt b/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/requires.txt b/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/requires.txt new file mode 100644 index 0000000..a8ef404 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/requires.txt @@ -0,0 +1 @@ +gnupg diff --git a/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/top_level.txt b/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/top_level.txt new file mode 100644 index 0000000..d4b84bf --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib-2.2.0.egg-info/top_level.txt @@ -0,0 +1,4 @@ +repolib +repolib/command +repolib/shortcuts +repolib/unittest diff --git a/repolib/usr/lib/python3/dist-packages/repolib/__init__.py b/repolib/usr/lib/python3/dist-packages/repolib/__init__.py new file mode 100644 index 0000000..4b4b7e5 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/__init__.py @@ -0,0 +1,144 @@ +#!/usr/bin/python3 + +""" +Copyright (c) 2019-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 +import logging.handlers as handlers + +from pathlib import Path + +from . import __version__ + +VERSION = __version__.__version__ + +from .file import SourceFile, SourceFileError +from .source import Source, SourceError +from .shortcuts import PPASource, PopdevSource, shortcut_prefixes +from .key import SourceKey, KeyFileError +from . import util +from . import system + +LOG_FILE_PATH = '/var/log/repolib.log' +LOG_LEVEL = logging.WARNING +KEYS_DIR = util.KEYS_DIR +SOURCES_DIR = util.SOURCES_DIR +TESTING = util.TESTING +KEYSERVER_QUERY_URL = util.KEYSERVER_QUERY_URL +DISTRO_CODENAME = util.DISTRO_CODENAME +PRETTY_PRINT = util.PRETTY_PRINT +CLEAN_CHARS = util.CLEAN_CHARS + +try: + from systemd.journal import JournalHandler + systemd_support = True +except ImportError: + systemd_support = False + +## Setup logging +stream_fmt = logging.Formatter( + '%(name)-21s: %(levelname)-8s %(message)s' +) +file_fmt = logging.Formatter( + '%(asctime)s - %(name)-21s: %(levelname)-8s %(message)s' +) +log = logging.getLogger(__name__) + +console_log = logging.StreamHandler() +console_log.setFormatter(stream_fmt) +console_log.setLevel(LOG_LEVEL) + +# file_log = handlers.RotatingFileHandler( +# LOG_FILE_PATH, maxBytes=(1048576*5), backupCount=5 +# ) +# file_log.setFormatter(file_fmt) +# file_log.setLevel(LOG_LEVEL) + +log.addHandler(console_log) +# log.addHandler(file_log) + +log_level_map:dict = { + 0: logging.WARNING, + 1: logging.INFO, + 2: logging.DEBUG +} + +if systemd_support: + journald_log = JournalHandler() # type: ignore (this is handled by the wrapping if) + journald_log.setLevel(logging.INFO) + journald_log.setFormatter(stream_fmt) + log.addHandler(journald_log) + +log.setLevel(logging.DEBUG) + +def set_testing(testing:bool = True) -> None: + """Puts Repolib into testing mode""" + global KEYS_DIR + global SOURCES_DIR + + util.set_testing(testing=testing) + KEYS_DIR = util.KEYS_DIR + SOURCES_DIR = util.SOURCES_DIR + + +def set_logging_level(level:int) -> None: + """Set the logging level for this current repolib + + Accepts an integer between 0 and 2, with 0 being the default loglevel of + logging.WARNING, 1 being logging.INFO, and 2 being logging.DEBUG. + + Values greater than 2 are clamped to 2. Values less than 0 are clamped to 0. + + Note: This only affects console output. Log file output remains + at logging.INFO + + Arguments: + level(int): A logging level from 0-2 + """ + if level > 2: + level = 2 + if level < 0: + level = 0 + LOG_LEVEL = log_level_map[level] + console_log.setLevel(LOG_LEVEL) + +RepoError = util.RepoError +SourceFormat = util.SourceFormat +SourceType = util.SourceType +AptSourceEnabled = util.AptSourceEnabled + +scrub_filename = util.scrub_filename +url_validator = util.url_validator +prettyprint_enable = util.prettyprint_enable +validate_debline = util.validate_debline +strip_hashes = util.strip_hashes +compare_sources = util.compare_sources +combine_sources = util.combine_sources +sources = util.sources +files = util.files +keys = util.keys +errors = util.errors + +valid_keys = util.valid_keys +options_inmap = util.options_inmap +options_outmap = util.options_outmap +true_values = util.true_values + +load_all_sources = system.load_all_sources diff --git a/repolib/usr/lib/python3/dist-packages/repolib/__version__.py b/repolib/usr/lib/python3/dist-packages/repolib/__version__.py new file mode 100644 index 0000000..a56edd5 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/__version__.py @@ -0,0 +1,22 @@ +#!/usr/bin/python3 + +""" +Copyright (c) 2019-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 . +""" +__version__ = "2.2.0" diff --git a/repolib/usr/lib/python3/dist-packages/repolib/command/__init__.py b/repolib/usr/lib/python3/dist-packages/repolib/command/__init__.py new file mode 100644 index 0000000..86f8e6c --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/command/__init__.py @@ -0,0 +1,30 @@ +#!/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 . +""" + +from .argparser import get_argparser +from .add import Add +from .list import List +from .remove import Remove +from .modify import Modify +from .key import Key + +parser = get_argparser() diff --git a/repolib/usr/lib/python3/dist-packages/repolib/command/add.py b/repolib/usr/lib/python3/dist-packages/repolib/command/add.py new file mode 100644 index 0000000..5ba3f14 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/command/add.py @@ -0,0 +1,267 @@ +#!/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 . +""" + +from httplib2.error import ServerNotFoundError +from urllib.error import URLError + +from .. import util +from ..source import Source, SourceError +from ..file import SourceFile, SourceFileError +from ..shortcuts import ppa, popdev, shortcut_prefixes +from .command import Command, RepolibCommandError + +class Add(Command): + """Add subcommand + + Adds a new source into the system. Requests root, if not present. + + Options: + --disable, d + --source-code, -s + --terse, -t + --name, -n + --identifier, -i + --format, -f + --skip-keys, -k + """ + + @classmethod + def init_options(cls, subparsers): + """ Sets up the argument parser for this command. + + Returns: argparse.subparser + The subparser for this command + """ + + sub = subparsers.add_parser( + 'add', + help='Add a new repository to the system' + ) + + sub.add_argument( + 'deb_line', + nargs='*', + default=['822styledeb'], + help='The deb line of the repository to add' + ) + sub.add_argument( + '-d', + '--disable', + action='store_true', + help='Add the repository and then set it to disabled.' + ) + sub.add_argument( + '-s', + '--source-code', + action='store_true', + help='Also enable source code packages for the repository.' + ) + sub.add_argument( + '-t', + '--terse', + action='store_true', + help='Do not display expanded info about a repository before adding it.' + ) + sub.add_argument( + '-n', + '--name', + default='', + help='A name to set for the new repo' + ) + sub.add_argument( + '-i', + '--identifier', + default='', + help='The filename to use for the new source' + ) + sub.add_argument( + '-f', + '--format', + default='sources', + help='The source format to save as, `sources` or `list`' + ) + sub.add_argument( + '-k', + '--skip-keys', + action='store_true', + help='Skip adding signing keys (not recommended!)' + ) + + return sub + + def finalize_options(self, args): + super().finalize_options(args) + self.deb_line = ' '.join(args.deb_line) + self.terse = args.terse + self.source_code = args.source_code + self.disable = args.disable + self.log.debug(args) + + try: + name = args.name.split() + except AttributeError: + name = args.name + + try: + ident = args.identifier.split() + except AttributeError: + ident = args.identifier + + self.name = ' '.join(name) + pre_ident:str = '-'.join(ident) + self.ident = util.scrub_filename(pre_ident) + self.skip_keys = args.skip_keys + self.format = args.format.lower() + + def run(self) -> bool: + """Run the command, return `True` if successful; otherwise `False`""" + if self.deb_line == '822styledeb': + self.parser.print_usage() + self.log.error('A repository is required') + return False + + repo_is_url = self.deb_line.startswith('http') + repo_is_nospaced = len(self.deb_line.split()) == 1 + + if repo_is_url and repo_is_nospaced: + self.deb_line = f'deb {self.deb_line} {util.DISTRO_CODENAME} main' + + print('Fetching repository information...') + + self.log.debug('Adding line %s', self.deb_line) + + new_source: Source = Source() + + for prefix in shortcut_prefixes: + self.log.debug('Trying prefix %s', prefix) + if self.deb_line.startswith(prefix): + self.log.debug('Line is prefix: %s', prefix) + new_source = shortcut_prefixes[prefix]() + if not new_source.validator(self.deb_line): + self.log.error( + 'The shortcut "%s" is malformed', + self.deb_line + ) + + # Try and get a suggested correction for common errors + try: + if self.deb_line[len(prefix)] != ':': + fixed_debline: str = self.deb_line.replace( + self.deb_line[len(prefix)], + ":", + 1 + ) + print(f'Did you mean "{fixed_debline}"?') + except IndexError: + pass + return False + + try: + new_source.load_from_data([self.deb_line]) + except (URLError, ServerNotFoundError) as err: + import traceback + self.log.debug( + 'Exception info: %s \n %s \n %s', + type(err), + ''.join(traceback.format_exception(err)), + err.args + ) + self.log.error( + 'System is offline. A connection is required to add ' + 'PPA and Popdev sources.' + ) + return False + except Exception as err: + import traceback + self.log.debug( + 'Exception info: %s \n %s \n %s', + type(err), + err.__traceback__, + err + ) + self.log.error('An error ocurred: %s', err) + return False + break + + + if not new_source: + self.log.error( + f'Could not parse line "{self.deb_line}". Double-check the ' + 'spelling.' + ) + valid_shortcuts: str = '' + for shortcut in shortcut_prefixes: + if shortcut.startswith('deb'): + continue + valid_shortcuts += f'{shortcut}, ' + valid_shortcuts = valid_shortcuts.strip(', ') + print(f'Supported repository shortcuts:\n {valid_shortcuts}') + return False + + new_source.twin_source = True + new_source.sourcecode_enabled = self.source_code + + if self.name: + new_source.name = self.name + if self.ident: + new_source.ident = self.ident + if self.disable: + new_source.enabled = False + + if not new_source.ident: + new_source.ident = new_source.generate_default_ident() + + new_file = SourceFile(name=new_source.ident) + new_file.format = new_source.default_format + if self.format: + self.log.info('Setting new source format to %s', self.format) + for format in util.SourceFormat: + if self.format == format.value: + new_file.format = format + self.log.debug('New source format set to %s', format.value) + + new_file.add_source(new_source) + new_source.file = new_file + self.log.debug('File format: %s', new_file.format) + self.log.debug('File path: %s', new_file.path) + + self.log.debug('Sources in file %s:\n%s', new_file.path, new_file.sources) + + if not self.terse: + print( + 'Adding the following source: \n', + new_source.get_description(), + '\n\n', + new_source.ui + ) + try: + input( + 'Press ENTER to continue adding this source or Ctrl+C ' + 'to cancel' + ) + except KeyboardInterrupt: + # Handled here to avoid printing the exception to console + exit(0) + + new_file.save() + util.dbus_quit() + return True diff --git a/repolib/usr/lib/python3/dist-packages/repolib/command/argparser.py b/repolib/usr/lib/python3/dist-packages/repolib/command/argparser.py new file mode 100644 index 0000000..182ccd7 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/command/argparser.py @@ -0,0 +1,65 @@ +#!/usr/bin/python3 + +""" +Copyright (c) 2020, 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 . + +Module for getting an argparser. Used by apt-manage +""" + +import argparse +import inspect + +from repolib import command as cmd + +from .. import __version__ + +def get_argparser(): + """ Get an argument parser with our arguments. + + Returns: + An argparse.ArgumentParser. + """ + parser = argparse.ArgumentParser( + prog='apt-manage', + description='Manage software sources and repositories', + epilog='apt-manage version: {}'.format(__version__.__version__) + ) + + parser.add_argument( + '-b', + '--debug', + action='count', + help=argparse.SUPPRESS + ) + + subparsers = parser.add_subparsers( + help='...', + dest='action', + metavar='COMMAND' + ) + + subcommands = [] + for i in inspect.getmembers(cmd, inspect.isclass): + obj = getattr(cmd, i[0]) + subcommands.append(obj) + + for i in subcommands: + i.init_options(subparsers) + + return parser diff --git a/repolib/usr/lib/python3/dist-packages/repolib/command/command.py b/repolib/usr/lib/python3/dist-packages/repolib/command/command.py new file mode 100644 index 0000000..b4afa41 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/command/command.py @@ -0,0 +1,77 @@ +""" +Copyright (c) 2020, 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 . + +Command line application helper class. +""" + +from ..util import SOURCES_DIR + +class RepolibCommandError(Exception): + """ Exceptions generated by Repolib CLIs.""" + + def __init__(self, *args, code=1, **kwargs): + """Exception from a CLI + + Arguments: + code (:obj:`int`, optional, default=1): Exception error code. + """ + super().__init__(*args, **kwargs) + self.code = code + +class Command: + # pylint: disable=no-self-use,too-few-public-methods + # This is a base class for other things to inherit and give other programs + # a standardized interface for interacting with commands. + """ CLI helper class for developing command line applications.""" + + def __init__(self, log, args, parser): + self.log = log + self.parser = parser + self.sources_dir = SOURCES_DIR + self.finalize_options(args) + + def finalize_options(self, args): + """ Base options parsing class. + + Use this class in commands to set up the final options parsing and set + instance variables for later use. + """ + self.verbose = False + self.debug = False + if args.debug: + if args.debug > 1: + self.verbose = True + if args.debug != 0: + self.log.info('Debug mode set, not-saving any changes.') + self.debug = True + + def run(self): + """ The initial base for running the command. + + This needs to have a standardized format, argument list, and return + either True or False depending on whether the command was successful or + not. + + Returns: + True if the command succeeded, otherwise False. + """ + + # Since this is just the base, it should always return True (because + # you shouldn't fail at doing nothing). + return True diff --git a/repolib/usr/lib/python3/dist-packages/repolib/command/key.py b/repolib/usr/lib/python3/dist-packages/repolib/command/key.py new file mode 100644 index 0000000..ccd97d1 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/command/key.py @@ -0,0 +1,375 @@ +#!/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 . +""" + +from pathlib import Path + +from ..key import SourceKey +from .. import system, util + +from .command import Command + +KEYS_PATH = Path(util.KEYS_DIR) + +class Key(Command): + """Key subcommand. + + Manages signing keys for repositories, allowing adding, removal, and + fetching of remote keys. + + Options: + --name, -n + --path, -p + --url, -u + --ascii, -a + --fingerprint, -f + --keyserver, -s + --remove, -r + """ + + @classmethod + def init_options(cls, subparsers) -> None: + """Sets up this command's options parser""" + + sub = subparsers.add_parser( + 'key', + help='Manage repository signing keys', + epilog=( + 'Note that no verification of key validity is performed when ' + 'managing key using apt-mange. Ensure that keys are valid to ' + 'avoid errors with updates.' + ) + ) + + sub.add_argument( + 'repository', + default=['x-repolib-none'], + help='Which repository to manage keys for.' + ) + + sub_group = sub.add_mutually_exclusive_group() + + sub_group.add_argument( + '-n', + '--name', + help='The name of an existing key file to set.' + ) + + sub_group.add_argument( + '-p', + '--path', + help='Sets a path on disk to a signing key.' + ) + + sub_group.add_argument( + '-u', + '--url', + help='Download a key over HTTPS' + ) + + sub_group.add_argument( + '-a', + '--ascii', + help='Add an ascii-armored key' + ) + + sub_group.add_argument( + '-f', + '--fingerprint', + help=( + 'Fetch a key via fingerprint from a keyserver. Use --keyserver ' + 'to specify the URL to the keyserver.' + ) + ) + + sub_group.add_argument( + '-i', + '--info', + action='store_true', + help='Print key information' + ) + + sub_group.add_argument( + '-r', + '--remove', + action='store_true', + help=( + 'Remove a signing key from the repo. If no other sources use ' + 'this key, its file will be deleted.' + ) + ) + + sub.add_argument( + '-s', + '--keyserver', + help=( + 'The keyserver from which to fetch the given fingerprint. ' + '(Default: keyserver.ubuntu.com)' + ) + ) + + def finalize_options(self, args): + super().finalize_options(args) + self.repo = args.repository + self.keyserver = args.keyserver + + self.actions:dict = {} + self.system_source = False + + for act in [ + 'name', + 'path', + 'url', + 'ascii', + 'fingerprint', + 'info', + 'remove', + ]: + self.actions[act] = getattr(args, act) + + system.load_all_sources() + + def run(self): + """Run the command""" + self.log.info('Modifying signing key settings for %s', self.repo) + + if not self.repo: + self.log.error('No repository provided') + return False + + try: + self.source = util.sources[self.repo] + if self.repo == 'system': + self.system_source = True + except KeyError: + self.log.error( + 'The repository %s was not found. Check the spelling', + self.repo + ) + return False + + self.log.debug('Actions to take:\n%s', self.actions) + # Run info, unless a different action is specified + self.actions['info'] = True + for key in self.actions: + if key == 'info': + self.log.debug('Skipping info key') + continue + if self.actions[key]: + self.log.info('Got an action, skipping info') + self.actions['info'] = False + break + + self.log.debug('Source before:\n%s', self.source) + + rets = [] + for action in self.actions: + if self.actions[action]: + self.log.debug('Running action: %s - (%s)', action, self.actions[action]) + ret = getattr(self, action)(self.actions[action]) + rets.append(ret) + break + + self.log.debug('Results: %s', rets) + self.log.debug('Source after: \n%s', self.source) + + if self.actions['info']: + self.log.info('Running Info, skipping saving %s', self.source.ident) + return True + + if True in rets: + self.log.info('Saving source %s', self.source.ident) + self.source.file.save() + return True + else: + self.log.warning('No valid changes specified, no actions taken.') + return False + + def name(self, value:str) -> bool: + """Sets the key file to a name in the key file directory""" + if not value: + return False + + key_path = None + + for ext in ['.gpg', '.asc']: + if value.endswith(ext): + path = KEYS_PATH / value + if path.exists(): + key_path = path + break + else: + if 'archive-keyring' not in value: + value += '-archive-keyring' + path = KEYS_PATH / f'{value}{ext}' + if path.exists(): + key_path = path + break + + if not key_path: + self.log.error('The key file %s could not be found', value) + return False + + return self.path(str(key_path)) + + def path(self, value:str) -> bool: + """Sets the key file to the given path""" + if not value: + return False + + key_path = Path(value) + + if not key_path.exists(): + self.log.error( + 'The path %s does not exist', value + ) + return False + + self.source.signed_by = str(key_path) + self.source.load_key() + return True + + def url(self, value:str) -> bool: + """Downloads a key to use from a provided URL.""" + if not value: + return False + + if not value.startswith('https:'): + response = 'n' + self.log.warning( + 'The key is not being downloaded over an encrypted connection, ' + 'and may be tampered with in-transit. Only add keys that you ' + 'trust, from sources which you trust.' + ) + response = input('Do you want to continue? (y/N): ') + if response not in util.true_values: + return False + + key = SourceKey(name=self.source.ident) + key.load_key_data(url=value) + self.source.key = key + self.source.signed_by = str(key.path) + self.source.load_key() + return True + + def ascii(self, value:str) -> bool: + """Loads the key from provided ASCII-armored data""" + if not value: + return False + + response = 'n' + print('Only add signing keys from data you trust.') + response = input('Do you want to continue? (y/N): ') + + if response not in util.true_values: + return False + + key = SourceKey(name=self.source.ident) + key.load_key_data(ascii=value) + self.source.key = key + self.source.signed_by = str(key.path) + self.source.load_key() + return True + + def fingerprint(self, value:str) -> bool: + """Downloads the key with the given fingerprint from a keyserver""" + if not value: + return False + + key = SourceKey(name=self.source.ident) + + if self.keyserver: + key.load_key_data(fingerprint=value, keyserver=self.keyserver) + else: + key.load_key_data(fingerprint=value) + + self.source.key = key + self.source.signed_by = str(key.path) + self.source.load_key() + return True + + def info(self, value:str) -> bool: + """Prints key information""" + from datetime import date + if not self.source.key: + self.log.error( + 'The source %s does not have a key configured.', + self.repo + ) + return True + + else: + key:dict = self.source.get_key_info() + output: str = f'Key information for source {self.source.ident}:\n' + output += f'Key ID: {key["uids"][0]}\n' + output += f'Fingerprint: {key["keyid"]}\n' + if key['type'] == 'pub': + output += 'Key Type: Public\n' + else: + output += 'Key Type: Private\n' + keydate = date.fromtimestamp(int(key['date'])) + output += f'Issue Date: {keydate.ctime()}\n' + output += f'Length: {key["length"]} Bytes\n' + output += f'Keyring Path: {str(self.source.key.path)}\n' + print(output) + + return True + + def remove(self, value:str) -> bool: + """Removes the key from the source""" + + if not self.source.key: + self.log.error( + 'The source %s does not have a key configured.', + self.repo + ) + return False + + response = 'n' + print( + 'If you remove the key, there may no longer be any verification ' + 'of software packages from this repository, including for future ' + 'updates. This may cause errors with your updates.' + ) + response = input('Do you want to continue? (y/N): ') + if response not in util.true_values: + return False + + old_key = self.source.key + self.source.key = None + self.source.signed_by = '' + + for source in util.sources.values(): + if source.key == old_key: + self.log.info( + 'Key file %s in use with another key, not deleting', + old_key.path + ) + return True + + response = 'n' + print('No other sources were found which use this key.') + response = input('Do you want to remove it? (Y/n): ') + if response in util.true_values: + old_key.delete_key() + + return True + diff --git a/repolib/usr/lib/python3/dist-packages/repolib/command/list.py b/repolib/usr/lib/python3/dist-packages/repolib/command/list.py new file mode 100644 index 0000000..aad121b --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/command/list.py @@ -0,0 +1,216 @@ +#!/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 argparse +import textwrap +import traceback + +from ..file import SourceFile, SourceFileError +from ..source import Source, SourceError +from .. import RepoError, util, system + +from .command import Command, RepolibCommandError + +class List(Command): + """List subcommand + + Lists information about currently-configured sources on the system. + + Options: + --legacy, -l + --verbose, -v + --all, -a + --no-names, -n + --file-names, -f + --no-indentation + """ + + @classmethod + def init_options(cls, subparsers): + """Sets up ths argument parser for this command. + + Returns: argparse.subparser: + This command's subparser + """ + + sub = subparsers.add_parser( + 'list', + help=( + 'List information for configured repostiories. If a repository ' + 'name is provided, details about that repository are printed.' + ) + ) + + sub.add_argument( + 'repository', + nargs='*', + default=['x-repolib-all-sources'], + help='The repository for which to list configuration' + ) + sub.add_argument( + '-v', + '--verbose', + action='store_true', + help='Show additional information, if available.' + ) + sub.add_argument( + '-a', + '--all', + action='store_true', + help='Display full configuration for all configured repositories.' + ) + sub.add_argument( + '-l', + '--legacy', + action='store_true', + help='Include repositories configured in legacy sources.list file.' + ) + sub.add_argument( + '-n', + '--no-names', + action='store_true', + dest='skip_names', + help=argparse.SUPPRESS + ) + sub.add_argument( + '-f', + '--file-names', + action='store_true', + dest='print_files', + help="Don't print names of files" + ) + sub.add_argument( + '--no-indentation', + action='store_true', + dest='no_indent', + help=argparse.SUPPRESS + ) + + def finalize_options(self, args): + super().finalize_options(args) + self.repo = ' '.join(args.repository) + self.verbose = args.verbose + self.all = args.all + self.legacy = args.legacy + self.skip_names = args.skip_names + self.print_files = args.print_files + self.no_indent = args.no_indent + + def list_legacy(self, indent) -> None: + """List the contents of the sources.list file. + + Arguments: + list_file(Path): The sources.list file to try and parse. + indent(str): An indentation to append to the output + """ + try: + sources_list_file = util.SOURCES_DIR.parent / 'sources.list' + except FileNotFoundError: + sources_list_file = None + + print('Legacy source.list sources:') + if sources_list_file: + with open(sources_list_file, mode='r') as file: + for line in file: + if 'cdrom' in line: + line = '' + + try: + source = Source() + source.load_from_data([line]) + print(textwrap.indent(source.ui, indent)) + except SourceError: + pass + + def list_all(self): + """List all sources presently configured in the system + + This may include the configuration data for each source as well + """ + + indent = ' ' + if self.no_indent: + indent = '' + + if self.print_files: + print('Configured source files:') + + for file in util.files: + print(f'{file.path.name}:') + + for source in file.sources: + print(textwrap.indent(source.ui, indent)) + + if self.legacy: + self.list_legacy(indent) + + else: + print('Configured Sources:') + for source in util.sources: + output = util.sources[source] + print(textwrap.indent(output.ui, indent)) + + if self.legacy: + self.list_legacy(indent) + + if util.errors: + print('\n\nThe following files contain formatting errors:') + for err in util.errors: + print(err) + if self.verbose or self.debug: + print('\nDetails about failing files:') + for err in util.errors: + print(f'{err}: {util.errors[err].args[0]}') + + return True + + def run(self): + """Run the command""" + system.load_all_sources() + self.log.debug("Current sources: %s", util.sources) + ret = False + + if self.all: + return self.list_all() + + if self.repo == 'x-repolib-all-sources' and not self.all: + if not self.skip_names: + print('Configured Sources:') + for source in util.sources: + line = source + if not self.skip_names: + line += f' - {util.sources[source].name}' + print(line) + + return True + + else: + try: + output = util.sources[self.repo] + print(f'Details for source {output.ui}') + return True + except KeyError: + self.log.error( + "Couldn't find the source file for %s, check the spelling", + self.repo + ) + return False diff --git a/repolib/usr/lib/python3/dist-packages/repolib/command/modify.py b/repolib/usr/lib/python3/dist-packages/repolib/command/modify.py new file mode 100644 index 0000000..2738684 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/command/modify.py @@ -0,0 +1,502 @@ +#!/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 . +""" + +from argparse import SUPPRESS + +from .command import Command, RepolibCommandError +from .. import util, system + +class Modify(Command): + """Modify Subcommand + + Makes modifications to the specified repository + + Options: + --enable, -e + --disable, -d + --default-mirror + --name + --add-suite + --remove-suite + --add-component + --remove-component + --add-uri + --remove-uri + + Hidden Options + --add-option + --remove-option + --default-mirror + """ + + @classmethod + def init_options(cls, subparsers): + """ Sets up this command's options parser""" + + sub = subparsers.add_parser( + 'modify', + help='Change a configured repository.' + ) + sub.add_argument( + 'repository', + nargs='*', + default=['system'], + help='The repository to modify. Default is the system repository.' + ) + + modify_enable = sub.add_mutually_exclusive_group( + required=False + ) + modify_enable.add_argument( + '-e', + '--enable', + action='store_true', + help='Enable the repository, if disabled.' + ) + modify_enable.add_argument( + '-d', + '--disable', + action='store_true', + help=( + 'Disable the repository, if enabled. The system repository cannot ' + 'be disabled.' + ) + ) + + modify_source = sub.add_mutually_exclusive_group( + required=False + ) + + modify_source.add_argument( + '--source-enable', + action='store_true', + help='Enable source code for the repository, if disabled.' + ) + modify_source.add_argument( + '--source-disable', + action='store_true', + help='Disable source code for the repository, if enabled.' + ) + + sub.add_argument( + '--default-mirror', + help=SUPPRESS + #help='Sets the default mirror for the system source.' + ) + + # Name + sub.add_argument( + '-n', + '--name', + help='Set the repository name to NAME' + ) + + # Suites + sub.add_argument( + '--add-suite', + metavar='SUITE[,SUITE]', + help=( + 'Add the specified suite(s) to the repository. Multiple ' + 'repositories should be separated with commas. NOTE: Legacy deb ' + 'repositories may only contain one suite.' + ) + ) + sub.add_argument( + '--remove-suite', + metavar='SUITE[,SUITE]', + help=( + 'Remove the specified suite(s) from the repository. Multiple ' + 'repositories should be separated with commas. NOTE: The last ' + 'suite in a repository cannot be removed.' + ) + ) + + # Components + sub.add_argument( + '--add-component', + metavar='COMPONENT[,COMPONENT]', + help=( + 'Add the specified component(s) to the repository. Multiple ' + 'repositories should be separated with commas.' + ) + ) + sub.add_argument( + '--remove-component', + metavar='COMPONENT[,COMPONENT]', + help=( + 'Remove the specified component(s) from the repository. Multiple ' + 'repositories should be separated with commas. NOTE: The last ' + 'component in a repository cannot be removed.' + ) + ) + + # URIs + sub.add_argument( + '--add-uri', + metavar='URI[,URI]', + help=( + 'Add the specified URI(s) to the repository. Multiple ' + 'repositories should be separated with commas. NOTE: Legacy deb ' + 'repositories may only contain one uri.' + ) + ) + sub.add_argument( + '--remove-uri', + metavar='URI[,URI]', + help=( + 'Remove the specified URI(s) from the repository. Multiple ' + 'repositories should be separated with commas. NOTE: The last ' + 'uri in a repository cannot be removed.' + ) + ) + + # Options + sub.add_argument( + '--add-option', + metavar='OPTION=VALUE[,OPTION=VALUE]', + help=SUPPRESS + ) + sub.add_argument( + '--remove-option', + metavar='OPTION=VALUE[,OPTION=VALUE]', + help=SUPPRESS + ) + + def finalize_options(self, args): + super().finalize_options(args) + self.count = 0 + self.repo = ' '.join(args.repository) + self.enable = args.enable + self.disable = args.disable + self.source_enable = args.source_enable + self.source_disable = args.source_disable + + self.actions:dict = {} + + self.actions['endisable'] = None + for i in ['enable', 'disable']: + if getattr(args, i): + self.actions['endisable'] = i + + self.actions['source_endisable'] = None + for i in ['source_enable', 'source_disable']: + if getattr(args, i): + self.actions['source_endisable'] = i + + for arg in [ + 'default_mirror', + 'name', + 'add_uri', + 'add_suite', + 'add_component', + 'remove_uri', + 'remove_suite', + 'remove_component', + ]: + self.actions[arg] = getattr(args, arg) + + system.load_all_sources() + + def run(self): + """Run the command""" + self.log.info('Modifying repository %s', self.repo) + + self.system_source = False + try: + self.source = util.sources[self.repo] + except KeyError: + self.log.error( + 'The source %s could not be found. Check the spelling', + self.repo + ) + return False + + if self.source.ident == 'system': + self.system_source = True + + self.log.debug('Actions to take:\n%s', self.actions) + self.log.debug('Source before:\n%s', self.source) + + rets = [] + for action in self.actions: + ret = getattr(self, action)(self.actions[action]) + rets.append(ret) + + self.log.debug('Results: %s', rets) + self.log.debug('Source after: \n%s', self.source) + + if True in rets: + self.source.file.save() + return True + else: + self.log.warning('No valid changes specified, no actions taken.') + return False + + def default_mirror(self, value:str) -> bool: + """Checks if this is the system source, then set the default mirror""" + if not value: + return False + + if self.system_source: + self.source['X-Repolib-Default-Mirror'] = value + return True + return False + + def name(self, value:str) -> bool: + """Sets the source name""" + if not value: + return False + + self.log.info('Setting name for %s to %s', self.repo, value) + self.source.name = value + return True + + def endisable(self, value:str) -> bool: + """Enable or disable the source""" + if not value: + return False + + self.log.info('%sing source %s', value[:-1], self.repo) + if value == 'disable': + self.source.enabled = False + return True + + self.source.enabled = True + return True + + def source_endisable(self, value:str) -> bool: + """Enable/disable source code for the repo""" + if not value: + return False + + self.log.info('%sing source code for source %s', value[7:-1], self.repo) + if value == 'source_disable': + self.source.sourcecode_enabled = False + return True + + self.source.sourcecode_enabled = True + return True + + def add_uri(self, values:str) -> bool: + """Adds URIs to the source, if not already present.""" + if not values: + return False + + self.log.info('Adding URIs: %s', values) + uris = self.source.uris + + for uri in values.split(): + if not util.url_validator(uri): + raise RepolibCommandError( + f'Cannot add URI {uri} to {self.repo}. The URI is ' + 'malformed' + ) + + if uri not in uris: + uris.append(uri) + self.log.debug('Added URI %s', uri) + + else: + self.log.warning( + 'The URI %s was already present in %s', + uri, + self.repo + ) + + if uris != self.source.uris: + self.source.uris = uris + return True + return False + + def remove_uri(self, values:str) -> bool: + """Remove URIs from the soruce, if they are added.""" + if not values: + return False + + self.log.info('Removing URIs %s from source %s', values, self.repo) + uris = self.source.uris + self.log.debug('Starting uris: %s', uris) + + for uri in values.split(): + try: + uris.remove(uri) + self.log.debug('Removed URI %s', uri) + + except ValueError: + self.log.warning( + 'The URI %s was not present in %s', + uri, + self.repo + ) + + if len(uris) == 0: + self.log.error( + 'Cannot remove the last URI from %s. If you meant to delete the source, try REMOVE instead.', + self.repo + ) + return False + + if uris != self.source.uris: + self.source.uris = uris + return True + + return False + + def add_suite(self, values:str) -> bool: + """Adds a suite to the source""" + if not values: + return False + + self.log.info('Adding suites: %s', values) + suites = self.source.suites + + for suite in values.split(): + if suite not in suites: + suites.append(suite) + self.log.debug('Added suite %s', suite) + + else: + self.log.warning( + 'The suite %s was already present in %s', + suite, + self.repo + ) + + if suites != self.source.suites: + self.source.suites = suites + return True + return False + + def remove_suite(self, values:str) -> bool: + """Remove a suite from the source""" + if not values: + return False + + self.log.info('Removing suites %s from source %s', values, self.repo) + suites = self.source.suites + self.log.debug('Starting suites: %s', suites) + + for suite in values.split(): + try: + suites.remove(suite) + self.log.debug('Removed suite %s', suite) + + except ValueError: + self.log.warning( + 'The suite %s was not present in %s', + suite, + self.repo + ) + + if len(suites) == 0: + self.log.error( + 'Cannot remove the last suite from %s. If you meant to delete the source, try REMOVE instead.', + self.repo + ) + return False + + if suites != self.source.suites: + self.source.suites = suites + return True + + return False + + def add_component(self, values:str) -> bool: + """Adds components to the source""" + if not values: + return False + + self.log.info('Adding components: %s', values) + components = self.source.components + + for component in values.split(): + if component not in components: + components.append(component) + self.log.debug('Added component %s', component) + + else: + self.log.warning( + 'The component %s was already present in %s', + component, + self.repo + ) + + if len(components) > 1: + if self.source.file.format == util.SourceFormat.LEGACY: + self.log.warning( + 'Adding multiple components to a legacy source is not ' + 'supported. Consider converting the source to DEB822 format.' + ) + + if components != self.source.components: + self.source.components = components + return True + return False + + def remove_component(self, values:str) -> bool: + """Removes components from the source""" + if not values: + return False + + self.log.info('Removing components %s from source %s', values, self.repo) + components = self.source.components + self.log.debug('Starting components: %s', components) + + for component in values.split(): + try: + components.remove(component) + self.log.debug('Removed component %s', component) + + except ValueError: + self.log.warning( + 'The component %s was not present in %s', + component, + self.repo + ) + + if len(components) == 0: + self.log.error( + 'Cannot remove the last component from %s. If you meant to delete the source, try REMOVE instead.', + self.repo + ) + return False + + if components != self.source.components: + self.source.components = components + return True + + return False + + def add_option(self, values) -> bool: + """TODO: Support options""" + raise NotImplementedError( + 'Options have not been implemented in this version of repolib yet. ' + f'Please edit the file {self.source.file.path} manually.' + ) + + + def remove_option(self, values) -> bool: + """TODO: Support options""" + raise NotImplementedError( + 'Options have not been implemented in this version of repolib yet. ' + f'Please edit the file {self.source.file.path} manually.' + ) diff --git a/repolib/usr/lib/python3/dist-packages/repolib/command/remove.py b/repolib/usr/lib/python3/dist-packages/repolib/command/remove.py new file mode 100644 index 0000000..851be03 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/command/remove.py @@ -0,0 +1,128 @@ +#!/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 . +""" + +from .. import util, system +from .command import Command + +class Remove(Command): + """Remove subcommand + + Removes configured sources from the system + + Options: + --assume-yes, -y + """ + + @classmethod + def init_options(cls, subparsers): + """Sets up the argument parser for this command + + Returns: argparse.subparser + This command's subparser + """ + + sub = subparsers.add_parser( + 'remove', + help='Remove a configured repository' + ) + + sub.add_argument( + 'repository', + help='The identifier of the repository to remove. See LIST' + ) + sub.add_argument( + '-y', + '--assume-yes', + action='store_true', + help='Remove without prompting for confirmation' + ) + + def finalize_options(self, args): + super().finalize_options(args) + system.load_all_sources() + self.source_name = args.repository + self.assume_yes = args.assume_yes + self.source = None + + def run(self): + """Run the command""" + + self.log.info('Looking up %s for removal', self.source_name) + + if self.source_name == 'system': + self.log.error('You cannot remove the system sources') + return False + + if self.source_name not in util.sources: + self.log.error( + 'Source %s was not found. Double-check the spelling', + self.source_name + ) + source_list:list = [] + for source in util.sources: + source_list.append(source) + suggested_source:str = self.source_name.replace(':', '-') + suggested_source = suggested_source.translate(util.CLEAN_CHARS) + if not suggested_source in source_list: + return False + + response:str = input(f'Did you mean "{suggested_source}"? (Y/n) ') + if not response: + response = 'y' + if response not in util.true_values: + return False + self.source_name = suggested_source + + self.source = util.sources[self.source_name] + self.key = self.source.key + self.file = self.source.file + + print(f'This will remove the source {self.source_name}') + print(self.source.ui) + response:str = 'n' + if self.assume_yes: + response = 'y' + else: + response = input('Are you sure you want to do this? (y/N) ') + + if response in util.true_values: + self.file.remove_source(self.source_name) + self.file.save() + + system.load_all_sources() + for source in util.sources.values(): + self.log.debug('Checking key for %s', source.ident) + try: + if source.key.path == self.key.path: + self.log.info('Source key in use with another source') + return True + except AttributeError: + pass + + self.log.info('No other sources found using key, deleting key') + if self.key: + self.key.delete_key() + return True + + else: + print('Canceled.') + return False diff --git a/repolib/usr/lib/python3/dist-packages/repolib/file.py b/repolib/usr/lib/python3/dist-packages/repolib/file.py new file mode 100644 index 0000000..5541a56 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/file.py @@ -0,0 +1,544 @@ +#!/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 + +import dbus + +from .source import Source, SourceError +from . import util + +FILE_COMMENT = "## Added/managed by repolib ##" + +class SourceFileError(util.RepoError): + """ Exception from a source file.""" + + def __init__(self, *args, code:int = 1, **kwargs): + """Exception with a source file + + Arguments: + :code int, optional, default=1: Exception error code. + """ + super().__init__(*args, **kwargs) + self.code: int = code + +class SourceFile: + """ A Source File on disk + + Attributes: + path(Pathlib.Path): the path for this file on disk + name(str): The name for this source (filename less the extension) + format(SourceFormat): The format used by this source file + contents(list): A list containing all of this file's contents + """ + + def __init__(self, name:str='') -> None: + """Initialize a source file + + Arguments: + name(str): The filename within the sources directory to load + """ + self.log = logging.getLogger(__name__) + self.name:str = '' + self.path:Path = Path() + self.alt_path:Path = Path() + self.format:util.SourceFormat = util.SourceFormat.DEFAULT + self.contents:list = [] + self.sources:list = [] + + self.contents.append(FILE_COMMENT) + self.contents.append('#') + + if name: + self.name = name + self.reset_path() + + if self.path.exists(): + self.contents = [] + self.load() + + def __str__(self): + return self.output + + def __repr__(self): + return f'SourceFile(name={self.name})' + + def add_source(self, source:Source) -> None: + """Adds a source to the file + + Arguments: + source(Source): The source to add + """ + if source not in self.sources: + self.contents.append(source) + self.sources.append(source) + source.file = self + + def remove_source(self, ident:str) -> None: + """Removes a source from the file + + Arguments: + ident(str): The ident of the source to remove + """ + source = self.get_source_by_ident(ident) + self.contents.remove(source) + self.sources.remove(source) + self.save() + + ## Remove sources prefs files/pin-priority + prefs_path = source.prefs + try: + if prefs_path.exists() and prefs_path.name: + prefs_path.unlink() + + except AttributeError: + # No prefs path + pass + + except PermissionError: + bus = dbus.SystemBus() + privileged_object = bus.get_object('org.pop_os.repolib', '/Repo') + privileged_object.delete_prefs_file(str(prefs_path)) + + def get_source_by_ident(self, ident: str) -> Source: + """Find a source within this file by its ident + + Arguments: + ident(str): The ident to search for + + Returns: Source + The located source + """ + self.log.debug(f'Looking up ident {ident} in {self.name}') + for source in self.sources: + if source.ident == ident: + self.log.debug(f'{ident} found') + return source + raise SourceFileError( + f'The file {self.path} does not contain the source {ident}' + ) + + def reset_path(self) -> None: + """Attempt to detect the correct path for this File. + + We default to DEB822 .sources format files, but if that file doesn't + exist, fallback to legacy .list format. If this also doesn't exist, we + swap back to DEB822 format, as this is likely a new file.""" + self.log.debug('Resetting path') + + default_path = util.SOURCES_DIR / f'{self.name}.sources' + legacy_path = util.SOURCES_DIR / f'{self.name}.list' + + if default_path.exists(): + self.path = default_path + self.alt_path = legacy_path + self.format = util.SourceFormat.DEFAULT + + elif legacy_path.exists(): + self.path = legacy_path + self.alt_path = default_path + self.format = util.SourceFormat.LEGACY + + else: + self.path = default_path + self.alt_path = legacy_path + + return + + def find_unique_ident(self, source1:Source, source2:Source) -> bool: + """Takes two sources with identical idents, and finds a new, unique + idents for them. + + The rules for this are mildly complicated, and vary depending on the + situation: + + * (DEB822) If the sources are identical other than some portion of + data, then the two will be combined into a single source. + * (legacy) If the two sources are identical other than source type + (common with legacy-format PPAs with source code) then the second + source will be dropped until export. + * (legacy) If the sources differ by URIs, Components, or Suites, then + the differing data will be appended to the sources' idents. + * (Either) If no other rules can be determined, then the sources will + have a number appended to them + + Arguments: + source1(Source): The original source with the ident + source2(Source): The new colliding source with the ident + + Returns: bool + `True` if the two sources were successfully deduped, `False` if the + second source should be discarded. + """ + ident_src1:str = source1.ident + ident_src2:str = source2.ident + + self.log.debug(f'Idents {ident_src1} and {ident_src2} conflict') + + if self.format == util.SourceFormat.DEFAULT: + util.combine_sources(source1, source2) + ident_src2 = '' + + else: + excl_keys = [ + 'X-Repolib-Name', + 'X-Repolib-ID', + 'X-Repolib-Comment', + 'Enabled', + 'Types' + ] + if len(source1.types) == 1 and len(source2.types) == 1: + if util.compare_sources(source1, source2, excl_keys): + util.combine_sources(source1, source2) + source1.types = [ + util.SourceType.BINARY, util.SourceType.SOURCECODE + ] + source1.twin_source = True + source1.sourcecode_enabled = source2.enabled + ident_src2 = '' + diffs = util.find_differences_sources(source1, source2, excl_keys) + if diffs: + for key in diffs: + raw_diffs:tuple = diffs[key] + diff1_list = raw_diffs[0].strip().split() + diff2_list = raw_diffs[1].strip().split() + for i in diff1_list: + if i not in diff2_list: + ident_src1 += f'-{i}' + break + for i in diff2_list: + if i not in diff1_list: + ident_src2 += f'-{i}' + break + if ident_src1 != ident_src2: + break + if ident_src2 and ident_src1 != ident_src2: + source1.ident = ident_src1 + source2.ident = ident_src2 + return True + + elif ident_src2 and ident_src1 == ident_src2: + for source in self.sources: + src_index = self.sources.index(source) + source.ident = f'{self.name}-{src_index}' + return True + + elif not ident_src2: + return False + + return True + + + def load(self) -> None: + """Loads the sources from the file on disk""" + self.log.debug(f'Loading source file {self.path}') + self.contents = [] + self.sources = [] + + if not self.name: + raise SourceFileError('You must provide a filename to load.') + + if not self.path.exists(): + raise SourceFileError(f'The file {self.path} does not exist.') + + with open(self.path, 'r') as source_file: + srcfile_data = source_file.readlines() + + item:int = 0 + raw822:list = [] + parsing_deb822:bool = False + source_name:str = '' + commented:bool = False + idents:dict = {} + + # Main file parsing loop + for line in srcfile_data: + comment_found:str = '' + name_line:bool = 'X-Repolib-Name' in line + + if not parsing_deb822: + commented = line.startswith('#') + + # Find commented out lines + if commented: + # Exclude disabled legacy deblines + valid_legacy = util.validate_debline(line.strip()) + if not valid_legacy and not name_line: + # Found a standard comment + self.contents.append(line.strip()) + + elif valid_legacy: + if self.format != util.SourceFormat.LEGACY: + raise SourceFileError( + f'File {self.path.name} is an updated file, but ' + 'contains legacy-format sources. This is not ' + 'allowed. Please fix the file manually.' + ) + new_source = Source() + new_source.load_from_data([line]) + if source_name: + new_source.name = source_name + if not new_source.ident: + new_source.ident = self.name + to_add:bool = True + if new_source.ident in idents: + old_source = idents[new_source.ident] + idents.pop(old_source.ident) + to_add = self.find_unique_ident(old_source, new_source) + idents[old_source.ident] = old_source + idents[new_source.ident] = new_source + if to_add: + new_source.file = self + self.contents.append(new_source) + self.sources.append(new_source) + + elif name_line: + source_name = ':'.join(line.split(':')[1:]) + source_name = source_name.strip() + + # Active legacy line + elif not commented: + if util.validate_debline(line.strip()): + if self.format != util.SourceFormat.LEGACY: + raise SourceFileError( + f'File {self.path.name} is an updated file, but ' + 'contains legacy-format sources. This is not ' + 'allowed. Please fix the file manually.' + ) + new_source = Source() + new_source.load_from_data([line]) + if source_name: + new_source.name = source_name + if not new_source.ident: + new_source.ident = self.name + to_add:bool = True + if new_source.ident in idents: + old_source = idents[new_source.ident] + idents.pop(old_source.ident) + to_add = self.find_unique_ident(old_source, new_source) + idents[old_source.ident] = old_source + idents[new_source.ident] = new_source + if to_add: + new_source.file = self + self.contents.append(new_source) + self.sources.append(new_source) + + # Empty lines are treated as comments + if line.strip() == '': + self.contents.append('') + + # Find 822 sources + # Valid sources can begin with any key: + for key in util.valid_keys: + if line.startswith(key): + if self.format == util.SourceFormat.LEGACY: + raise SourceFileError( + f'File {self.path.name} is a DEB822-format file, but ' + 'contains legacy sources. This is not allowed. ' + 'Please fix the file manually.' + ) + parsing_deb822 = True + raw822.append(line.strip()) + + item += 1 + + elif parsing_deb822: + # Deb822 sources are terminated with an empty line + if line.strip() == '': + parsing_deb822 = False + new_source = Source() + new_source.load_from_data(raw822) + new_source.file = self + if source_name: + new_source.name = source_name + if not new_source.ident: + new_source.ident = self.name + if new_source.ident in idents: + old_source = idents[new_source.ident] + idents.pop(old_source.ident) + self.find_unique_ident(old_source, new_source) + idents[old_source.ident] = old_source + idents[new_source.ident] = new_source + new_source.file = self + self.contents.append(new_source) + self.sources.append(new_source) + raw822 = [] + item += 1 + self.contents.append('') + else: + raw822.append(line.strip()) + + if raw822: + parsing_deb822 = False + new_source = Source() + new_source.load_from_data(raw822) + new_source.file = self + if source_name: + new_source.name = source_name + if not new_source.ident: + new_source.ident = self.name + if new_source.ident in idents: + old_source = idents[new_source.ident] + idents.pop(old_source.ident) + self.find_unique_ident(old_source, new_source) + idents[old_source.ident] = old_source + idents[new_source.ident] = new_source + new_source.file = self + self.contents.append(new_source) + self.sources.append(new_source) + raw822 = [] + item += 1 + self.contents.append('') + + for source in self.sources: + if not source.has_required_parts: + raise SourceFileError( + f'The file {self.path.name} is malformed and contains ' + 'errors. Maybe it has some extra new-lines?' + ) + + self.log.debug('File %s loaded', self.path) + + def save(self) -> None: + """Saves the source file to disk using the current format""" + self.log.debug(f'Saving source file to {self.path}') + + for source in self.sources: + self.log.debug('New Source %s: \n%s', source.ident, source) + + save_path = util.SOURCES_DIR / f'{self.name}.save' + + for source in self.sources: + if source.key: + source.key.save_gpg() + source.tasks_save() + + if not self.name or not self.format: + raise SourceFileError('There was not a complete filename to save') + + if not util.SOURCES_DIR.exists(): + try: + util.SOURCES_DIR.mkdir(parents=True) + except PermissionError: + self.log.error( + 'Source destination path does not exist and cannot be created ' + 'Failures expected now.' + ) + + if len(self.sources) > 0: + self.log.debug('Saving, Main path %s; Alt path: %s', self.path, self.alt_path) + try: + with open(self.path, mode='w') as output_file: + output_file.write(self.output) + if self.alt_path.exists(): + self.alt_path.rename(save_path) + + except PermissionError: + bus = dbus.SystemBus() + privileged_object = bus.get_object('org.pop_os.repolib', '/Repo') + privileged_object.output_file_to_disk(self.path.name, self.output) + self.log.debug('File %s saved', self.path) + else: + try: + self.path.unlink(missing_ok=True) + self.alt_path.unlink(missing_ok=True) + save_path.unlink(missing_ok=True) + except PermissionError: + bus = dbus.SystemBus() + privileged_object = bus.get_object('org.pop_os.repolib', '/Repo') + privileged_object.delete_source_file(self.path.name) + self.log.debug('File %s removed', self.path) + + + ## Attribute properties + @property + def format(self) -> util.SourceFormat: # type: ignore (We don't use str.format) + """The format of the file on disk""" + return self._format + + @format.setter + def format(self, format:util.SourceFormat) -> None: # type: ignore (We don't use str.format) + """The path needs to be updated when the format changes""" + alt_format:util.SourceFormat = util.SourceFormat.LEGACY + self._format = format + self.path = util.SOURCES_DIR / f'{self.name}.{self._format.value}' + for format_ in util.SourceFormat: + if format != format_: + alt_format = format_ + self.alt_path = util.SOURCES_DIR / f'{self.name}.{alt_format.value}' + + ## Output properties + @property + def legacy(self) -> str: + """Outputs the file in the output_legacy format""" + legacy_output:str = '' + for item in self.contents: + try: + legacy_output += item.legacy + except AttributeError: + legacy_output += item + legacy_output += '\n' + return legacy_output + + @property + def deb822(self) -> str: + """Outputs the file in the output_822 format""" + deb822_output:str = '' + for item in self.contents: + try: + deb822_output += item.deb822 + except AttributeError: + deb822_output += item + deb822_output += '\n' + return deb822_output + + + @property + def ui(self) -> str: + """Outputs the file in the output_ui format""" + ui_output:str = '' + for item in self.contents: + try: + ui_output += item.ui + except AttributeError: + pass # Skip file comments in UI mode + ui_output += '\n' + return ui_output + + + @property + def output(self) -> str: + """Outputs the file in the output format""" + default_output:str = '' + for item in self.contents: + try: + if self.format == util.SourceFormat.DEFAULT: + default_output += item.deb822 + elif self.format == util.SourceFormat.LEGACY: + default_output += item.legacy + default_output += '\n' + except AttributeError: + default_output += item + default_output += '\n' + return default_output + diff --git a/repolib/usr/lib/python3/dist-packages/repolib/key.py b/repolib/usr/lib/python3/dist-packages/repolib/key.py new file mode 100644 index 0000000..766881b --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/key.py @@ -0,0 +1,204 @@ +#!/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 +import shutil + +import dbus +import gnupg +from pathlib import Path +from urllib import request + +from . import util + +SKS_KEYSERVER = 'https://keyserver.ubuntu.com/' +SKS_KEYLOOKUP_PATH = 'pks/lookup?op=get&options=mr&exact=on&search=0x' + +class KeyFileError(util.RepoError): + """ Exceptions related to apt key files.""" + + def __init__(self, *args, code=1, **kwargs): + """Exceptions related to apt key files. + + Arguments: + code (:obj:`int`, optional, default=1): Exception error code. + """ + super().__init__(*args, **kwargs) + self.code = code + +class SourceKey: + """A signing key for an apt source.""" + + def __init__(self, name:str = '') -> None: + self.log = logging.getLogger(__name__) + self.tmp_path = Path() + self.path = Path() + self.gpg = gnupg.GPG() + self.data = b'' + + if name: + self.reset_path(name=name) + self.setup_gpg() + + def reset_path(self, name: str = '', path:str = '', suffix: str = 'archive-keyring') -> None: + """Set the path for this key + + Arguments: + suffix(str): The suffix to append to the end of the name to get the + file name (default: 'archive-keyring') + name(str): The name of the source + path(str): The entire path to the key + """ + self.log.info('Setting path') + if not name and not path: + raise KeyFileError('A name is required to set the path for this key') + + if name: + file_name = f'{name}-{suffix}.gpg' + self.tmp_path = util.TEMP_DIR / file_name + self.path = util.KEYS_DIR / file_name + elif path: + self.path = Path(path) + self.tmp_path = util.TEMP_DIR / self.path.name + + self.setup_gpg() + + self.log.debug('Key Path: %s', self.path) + self.log.debug('Temp Path: %s', self.tmp_path) + + def setup_gpg(self) -> None: + """Set up the GPG object for this key.""" + self.log.info('Setting up GPG') + self.log.debug('Copying %s to %s', self.path, self.tmp_path) + try: + shutil.copy2(self.path, self.tmp_path) + + except FileNotFoundError: + pass + + self.gpg = gnupg.GPG(keyring=str(self.tmp_path)) + self.log.debug('GPG Setup: %s', self.gpg.keyring) + + def save_gpg(self) -> None: + """Saves the key to disk.""" + self.log.info('Saving key file %s from %s', self.path, self.tmp_path) + self.log.debug('Key contents: %s', self.gpg.list_keys()) + self.log.debug('Temp key exists? %s', self.tmp_path.exists()) + if not util.KEYS_DIR.exists(): + try: + util.KEYS_DIR.mkdir(parents=True) + except PermissionError: + self.log.error( + 'Key destination path does not exist and cannot be created ' + 'Failures expected now.' + ) + try: + shutil.copy(self.tmp_path, self.path) + + except PermissionError: + bus = dbus.SystemBus() + privileged_object = bus.get_object('org.pop_os.repolib', '/Repo') + privileged_object.install_signing_key( + str(self.tmp_path), + str(self.path) + ) + + def delete_key(self) -> None: + """Deletes the key file from disk.""" + try: + self.tmp_path.unlink() + self.path.unlink() + + except PermissionError: + bus = dbus.SystemBus() + privileged_object = bus.get_object('org.pop_os.repolib', '/Repo') + privileged_object.delete_signing_key(str(self.path)) + + except FileNotFoundError: + pass + + def load_key_data(self, **kwargs) -> None: + """Loads the key data from disk into the object for processing. + + Each of the keyword options specifies one place to look to import key + data. Once one is successfully imported, the method returns, so passing + multiple won't import multiple keys. + + Keyword Arguments: + raw(bytes): Raw data to import to the keyring + ascii(str): ASCII-armored key data to import directly + url(str): A URL to download key data from + fingerprint(str): A key fingerprint to download from `keyserver` + keyserver(str): A keyserver to download from. + keypath(str): The path on the keyserver from which to download. + + NOTE: The keyserver and keypath args only affect the operation of the + `fingerprint` keyword. + """ + + if self.path.exists(): + with open(self.path, mode='rb') as keyfile: + self.data = keyfile.read() + return + + self.tmp_path.touch() + + if 'raw' in kwargs: + self.data = kwargs['raw'] + self.gpg.import_keys(self.data) + return + + if 'ascii' in kwargs: + self.gpg.import_keys(kwargs['ascii']) + if self.tmp_path.exists(): + with open(self.tmp_path, mode='rb') as keyfile: + self.data = keyfile.read() + return + + if 'url' in kwargs: + req = request.Request(kwargs['url']) + with request.urlopen(req) as response: + self.data = response.read().decode('UTF-8') + self.gpg.import_keys(self.data) + return + + if 'fingerprint' in kwargs: + if not 'keyserver' in kwargs: + kwargs['keyserver'] = SKS_KEYSERVER + + if not 'keypath' in kwargs: + kwargs['keypath'] = SKS_KEYLOOKUP_PATH + + key_url = kwargs['keyserver'] + kwargs['keypath'] + kwargs['fingerprint'] + req = request.Request(key_url) + with request.urlopen(req) as response: + self.data = response.read().decode('UTF-8') + self.gpg.import_keys(self.data) + return + + raise TypeError( + f'load_key_data() got an unexpected keyword argument "{kwargs.keys()}', + ' Expected keyword arguments are: [raw, ascii, url, fingerprint]' + ) + + + diff --git a/repolib/usr/lib/python3/dist-packages/repolib/parsedeb.py b/repolib/usr/lib/python3/dist-packages/repolib/parsedeb.py new file mode 100644 index 0000000..1e0d992 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/parsedeb.py @@ -0,0 +1,371 @@ +#!/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 . import util + +log = logging.getLogger(__name__) + +class DebParseError(util.RepoError): + """ Exceptions related to parsing deb lines.""" + + def __init__(self, *args, code=1, **kwargs): + """Exceptions related to parsing deb lines. + + Arguments: + code (:obj:`int`, optional, default=1): Exception error code. + """ + super().__init__(*args, **kwargs) + self.code = code + +def debsplit(line:str) -> list: + """ Improved string.split() with support for things like [] options. + + Adapted from python-apt + + Arguments: + line(str): The line to split up. + """ + line = line.strip() + line_list = line.split() + for i in line_list: + if util.url_validator(i): + line_list[line_list.index(i)] = decode_brackets(i) + line = ' '.join(line_list) + pieces:list = [] + tmp:str = "" + # we are inside a [..] block + p_found = False + for char in line: + if char == '[': + p_found = True + tmp += char + elif char == ']': + p_found = False + tmp += char + elif char.isspace() and not p_found: + pieces.append(tmp) + tmp = '' + continue + else: + tmp += char + # append last piece + if len(tmp) > 0: + pieces.append(tmp) + return pieces + +def encode_brackets(word:str) -> str: + """ Encodes any [ and ] brackets into URL-safe form + + Technically we should never be recieving these, and there are other things + which should technically be encoded as well. However, square brackets + actively break the URL parsing, and must be strictly avoided. + + Arguments: + word (str): the string to encode brackets in. + + Returns: + `str`: the encoded string. + """ + word = word.replace('[', '%5B') + word = word.replace(']', '%5D') + return word + +def decode_brackets(word:str) -> str: + """ Un-encodes [ and ] from the input + + Since our downstream libraries should also be encoding these correctly, it + is better to retain these as the user entered, as that ensures they can + recognize it properly. + + Arguments: + word (str): The string to decode. + + Returns: + `str`: the decoded string. + """ + word = word.replace('%5B', '[') + word = word.replace('%5D', ']') + return word + +def parse_name_ident(tail:str) -> tuple: + """ Find a Repolib name within the given comment string. + + The name should be headed with "X-Repolib-Name:" and is not space terminated. + The ident should be headed with "X-Repolib-ID:" and is space terminated. + + Either field ends at the end of a line, or at a subsequent definition of a + different field, or at a subsequent ' #' substring. Additionally, the ident + field ends with a subsequent space. + + Arguments: + tail (str): The comment to search within. + + Returns: tuple(name, ident, comment): + name (str): The detected name, or None + ident (str): The detected ident, or None + comment (str): The string with the name and ident removed + """ + tail = util.strip_hashes(tail) + + # Used for sanity checking later + has_name = 'X-Repolib-Name' in tail + log.debug('Line name found: %s', has_name) + has_ident = 'X-Repolib-ID' in tail + log.debug('Line ident found: %s', has_ident) + + parts: list = tail.split() + name_found = False + ident_found = False + name:str = '' + ident:str = '' + comment:str = '' + for item in parts: + log.debug("Checking line item: %s", item) + item_is_name = item.strip('#').strip().startswith('X-Repolib-Name') + item_is_ident = item.strip('#').strip().startswith('X-Repolib-ID') + + if '#' in item and not item_is_name and not item_is_ident: + name_found = False + ident_found = False + + elif item_is_name: + name_found = True + ident_found = False + continue + + elif item_is_ident: + name_found = False + ident_found = True + continue + + if name_found and not item_is_name: + name += f'{item} ' + continue + + elif ident_found and not item_is_ident: + ident += f'{item}' + ident_found = False + continue + + elif not name_found and not ident_found: + c = item.strip('#') + comment += f'{c} ' + + name = name.strip() + ident = ident.strip() + comment = comment.strip() + + if not name: + if ident: + name = ident + + # Final sanity checking + if has_name and not name: + raise DebParseError( + f'Could not parse repository name from comment {comment}. Make sure ' + 'you have a space between the colon and the Name' + ) + if has_ident and not ident: + raise DebParseError( + f'Could not parse repository ident from comment {comment}. Make sure ' + 'you have a space between the colon and the Ident' + ) + + return name, ident, comment + + +class ParseDeb: + """ Parsing for source entries. + + Contains parsing helpers for one-line format sources. + """ + + def __init__(self, debug:bool = False) -> None: + """ + Arguments: + debug (bool): In debug mode, the structured data is always returned + at the end, instead of checking for sanity (default: `False`) + """ + self.debug = debug + self.last_line: str = '' + self.last_line_valid: bool = False + self.curr_line: str = '' + self.curr_line_valid: bool = False + + def parse_options(self, opt:str) -> dict: + """ Parses a string of options into a dictionary that repolib can use. + + Arguments: + opt(str): The string with options returned from the line parser. + + Returns: + `dict`: The dictionary of options with key:val pairs (may be {}) + """ + opt = opt.strip() + opt = opt[1:-1].strip() # Remove enclosing brackets + options = opt.split() + + parsed_options:dict = {} + + for opt in options: + pre_key, values = opt.split('=') + values = values.split(',') + value:str = ' '.join(values) + try: + key:str = util.options_inmap[pre_key] + except KeyError: + raise DebParseError( + f'Could not parse line {self.curr_line}: option {opt} is ' + 'not a valid debian repository option or is unsupported.' + ) + parsed_options[key] = value + + return parsed_options + + + def parse_line(self, line:str) -> dict: + """ Parse a deb line into its individual parts. + + Adapted from python-apt + + Arguments: + line (str): The line input to parse + + Returns: + (dict): a dict containing the requisite data. + """ + self.last_line = self.curr_line + self.last_line_valid = self.curr_line_valid + self.curr_line = line.strip() + parts:list = [] + + line_is_comment = self.curr_line == '#' + line_is_empty = self.curr_line == '' + if line_is_comment or line_is_empty: + raise DebParseError(f'Current line "{self.curr_line}" is empty') + + line_parsed: dict = {} + line_parsed['enabled'] = True + line_parsed['name'] = '' + line_parsed['ident'] = '' + line_parsed['comments'] = [] + line_parsed['repo_type'] = '' + line_parsed['uri'] = '' + line_parsed['suite'] = '' + line_parsed['components'] = [] + line_parsed['options'] = {} + + if line.startswith('#'): + line_parsed['enabled'] = False + line = util.strip_hashes(line) + parts = line.split() + if not parts[0] in ('deb', 'deb-src'): + raise DebParseError(f'Current line "{self.curr_line}" is invalid') + + comments_index = line.find('#') + if comments_index > 0: + raw_comments:str = line[comments_index + 1:].strip() + ( + line_parsed['name'], + line_parsed['ident'], + comments + ) = parse_name_ident(raw_comments) + line_parsed['comments'].append(comments) + line = line[:comments_index] + + parts = debsplit(line) + if len(parts) < 3: # We need at least a type, a URL, and a component + raise DebParseError( + f'The line "{self.curr_line}" does not have enough pieces to be' + 'valid' + ) + # Determine the type of the repo + repo_type:str = parts.pop(0) + if repo_type in ['deb', 'deb-src']: + line_parsed['repo_type'] = util.SourceType(repo_type) + else: + raise DebParseError(f'The line "{self.curr_line}" is of invalid type.') + + # Determine the properties of our repo line + uri_index:int = 0 + is_cdrom: bool = False + ## The URI index is the vital piece of information we need to parse the + ## deb line, as it's position determines what other components are + ## present and where they are. This determines the location of the URI + ## regardless of where it's at. + for part in parts: + if part.startswith('['): + if 'cdrom' in part: + is_cdrom = True + uri_index = parts.index(part) + else: + uri_index = 1 + + if is_cdrom: + # This could maybe change if the parser now differentiates between + # CDROM URIs and option lists + raise DebParseError('Repolib cannot currently accept CDROM Sources') + + if uri_index != 0: + line_parsed['options'] = self.parse_options(parts.pop(0)) + + if len(line_parsed) < 2: # Should have at minimum a URI and a suite/path + raise DebParseError( + f'The line "{self.curr_line}" does not have enough pieces to be' + 'valid' + ) + + line_uri = parts.pop(0) + if util.url_validator(line_uri): + line_parsed['uri'] = line_uri + + else: + raise DebParseError( + f'The line "{self.curr_line}" has invalid URI: {line_uri}' + ) + + line_parsed['suite'] = parts.pop(0) + + line_components:list = [] + for comp in parts: + line_parsed['components'].append(comp) + + + has_type = line_parsed['repo_type'] + has_uri = line_parsed['uri'] + has_suite = line_parsed['suite'] + + if has_type and has_uri and has_suite: + # if we have these three minimum components, we can proceed and the + # line is valid. Otherwise, error out. + return line_parsed.copy() + + if self.debug: + return line_parsed.copy() + + raise DebParseError( + f'The line {self.curr_line} could not be parsed due to an ' + 'unknown error (Probably missing the repo type, URI, or a ' + 'suite/path).' + ) diff --git a/repolib/usr/lib/python3/dist-packages/repolib/shortcuts/__init__.py b/repolib/usr/lib/python3/dist-packages/repolib/shortcuts/__init__.py new file mode 100644 index 0000000..43c1503 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/shortcuts/__init__.py @@ -0,0 +1,32 @@ +#!/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 . +""" + +from .popdev import PopdevSource +from .ppa import PPASource +from ..source import Source + +shortcut_prefixes = { + 'deb': Source, + 'deb-src': Source, + ppa.prefix: ppa.PPASource, + popdev.prefix: popdev.PopdevSource +} \ No newline at end of file diff --git a/repolib/usr/lib/python3/dist-packages/repolib/shortcuts/popdev.py b/repolib/usr/lib/python3/dist-packages/repolib/shortcuts/popdev.py new file mode 100644 index 0000000..f4efc6e --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/shortcuts/popdev.py @@ -0,0 +1,171 @@ +#!/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 + +import dbus + +from repolib.key import SourceKey + +from ..source import Source, SourceError +from ..file import SourceFile +from .. import util + +BASE_FORMAT = util.SourceFormat.DEFAULT +BASE_URL = 'http://apt.pop-os.org/staging' +BASE_COMPS = 'main' +BASE_KEYURL = 'https://raw.githubusercontent.com/pop-os/pop/master/scripts/.iso.asc' + +DEFAULT_FORMAT = util.SourceFormat.DEFAULT + +prefix = 'popdev' +delineator = ':' + +class PopdevSource(Source): + """ PopDev Source shortcut + + These are given in the format popdev:branchname. + + Arguments: + shortcut (str): The ppa: shortcut to process + """ + prefs_dir = Path('/etc/apt/preferences.d') + default_format = BASE_FORMAT + + @staticmethod + def validator(shortcut:str) -> bool: + """Determine whether a PPA shortcut is valid. + + Arguments: + shortcut(str): The shortcut to validate + + Returns: bool + `True` if the PPA is valid, otherwise False + """ + if '/' in shortcut: + return False + + shortcut_split = shortcut.split(':') + try: + if not shortcut_split[1]: + return False + except IndexError: + return False + + if shortcut.startswith(f'{prefix}:'): + shortlist = shortcut.split(':') + if len(shortlist) > 0: + return True + + return False + + def __init__(self, *args, line='', fetch_data=True, **kwargs): + if line: + if not line.startswith('ppa:'): + raise SourceError(f'The PPA shortcut {line} is malformed') + super().__init__(args, kwargs) + self.log = logging.getLogger(__name__) + self.line = line + self.twin_source:bool = True + self.prefs_path = None + self.branch_name:str = '' + self.branch_url:str = '' + if line: + self.load_from_shortcut(line) + + def tasks_save(self, *args, **kwargs) -> None: + super().tasks_save(*args, **kwargs) + self.log.info('Saving prefs file for %s', self.ident) + prefs_contents = 'Package: *\n' + prefs_contents += f'Pin: release o=pop-os-staging-{self.branch_url}\n' + prefs_contents += 'Pin-Priority: 1002\n' + + self.log.debug('%s prefs for pin priority:\n%s', self.ident, prefs_contents) + + try: + with open(self.prefs, mode='w') as prefs_file: + prefs_file.write(prefs_contents) + except PermissionError: + bus = dbus.SystemBus() + privileged_object = bus.get_object('org.pop_os.repolib', '/Repo') + privileged_object.output_prefs_to_disk(str(self.prefs), prefs_contents) + + self.log.debug('Pin priority saved for %s', self.ident) + + + def get_description(self) -> str: + return f'Pop Development Staging branch' + + + def load_from_data(self, data: list) -> None: + self.log.debug('Loading line %s', data[0]) + self.load_from_shortcut(shortcut=data[0]) + + def load_from_shortcut(self, shortcut:str='', meta:bool=True, get_key:bool=True) -> None: + """Translates the shortcut line into a full repo. + + Arguments: + shortcut(str): The shortcut to load, if one hasn't been loaded yet. + """ + self.reset_values() + if shortcut: + self.line = shortcut + + if not self.line: + raise SourceError('No PPA shortcut provided') + + if not self.validator(self.line): + raise SourceError(f'The line {self.line} is malformed') + + self.log.debug('Loading shortcut %s', self.line) + + self.info_parts = shortcut.split(delineator) + self.branch_url = ':'.join(self.info_parts[1:]) + self.branch_name = util.scrub_filename(name=self.branch_url) + self.log.debug('Popdev branch name: %s', self.branch_name) + + self.ident = f'{prefix}-{self.branch_name}' + if f'{self.ident}.{BASE_FORMAT.value}' not in util.files: + new_file = SourceFile(name=self.ident) + new_file.format = BASE_FORMAT + self.file = new_file + util.files[str(self.file.path)] = self.file + else: + self.file = util.files[str(self.file.path)] + + self.file.add_source(self) + + self.name = f'Pop Development Branch {self.branch_name}' + self.uris = [f'{BASE_URL}/{self.branch_url}'] + self.suites = [util.DISTRO_CODENAME] + self.components = [BASE_COMPS] + + key = SourceKey(name='popdev') + key.load_key_data(url=BASE_KEYURL) + self.key = key + self.signed_by = str(self.key.path) + + self.prefs_path = self.prefs_dir / f'pop-os-staging-{self.branch_name}' + self.prefs = self.prefs_path + + self.enabled = True diff --git a/repolib/usr/lib/python3/dist-packages/repolib/shortcuts/ppa.py b/repolib/usr/lib/python3/dist-packages/repolib/shortcuts/ppa.py new file mode 100644 index 0000000..624ac4e --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/shortcuts/ppa.py @@ -0,0 +1,274 @@ +#!/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 repolib.key import SourceKey + +from ..source import Source, SourceError +from ..file import SourceFile +from .. import util + +try: + from launchpadlib.launchpad import Launchpad + from lazr.restfulclient.errors import BadRequest, NotFound, Unauthorized +except ImportError: + raise SourceError( + 'Missing optional dependency "launchpadlib". Try `sudo apt install ' + 'python3-launchpadlib` to install it.' + ) + +BASE_FORMAT = util.SourceFormat.LEGACY +BASE_URL = 'http://ppa.launchpad.net' +BASE_DIST = 'ubuntu' +BASE_COMPS = 'main' + +DEFAULT_FORMAT = util.SourceFormat.LEGACY + +prefix = 'ppa' +delineator = ':' + +class PPASource(Source): + """ PPA Source shortcut + + These are given in the format ppa:owner/name. Much of this code is adapted + from SoftwareProperties. + + Arguments: + shortcut (str): The ppa: shortcut to process + fetch_data (bool): Whether to try and fetch metadata from Launchpad. + """ + + default_format = BASE_FORMAT + + @staticmethod + def validator(shortcut:str) -> bool: + """Determine whether a PPA shortcut is valid. + + Arguments: + shortcut(str): The shortcut to validate + + Returns: bool + `True` if the PPA is valid, otherwise False + """ + + if shortcut.startswith(f'{prefix}:'): + shortlist = shortcut.split('/') + if len(shortlist) > 1: + return True + return False + + def __init__(self, *args, line='', fetch_data=True, **kwargs): + if line: + if not line.startswith('ppa:'): + raise SourceError(f'The PPA shortcut {line} is malformed') + super().__init__(args, kwargs) + self.log = logging.getLogger(__name__) + self.line = line + self.ppa = None + self.twin_source = True + self._displayname = '' + self._description = '' + if line: + self.load_from_shortcut(self.line) + + def get_description(self) -> str: + output:str = '' + output += self.displayname + output += '\n\n' + output += self.description + return output + + def load_from_data(self, data: list) -> None: + self.load_from_shortcut(shortcut=data[0]) + + def load_from_shortcut(self, shortcut:str='', meta:bool=True, key:bool=True) -> None: + """Translates the shortcut line into a full repo. + + Arguments: + shortcut(str): The shortcut to load, if one hasn't been loaded yet. + meta(bool): Whether to fetch repo metadata from Launchpad + key(bool): Whether to fetch and install a signing key + """ + self.reset_values() + if shortcut: + self.line = shortcut + + if not self.line: + raise SourceError('No PPA shortcut provided') + + if not self.validator(self.line): + raise SourceError(f'The line {self.line} is malformed') + + line = self.line.replace(prefix + delineator, '') + self.info_parts = line.split('/') + ppa_owner = self.info_parts[0] + ppa_name = self.info_parts[1] + + self.ident = f'{prefix}-{ppa_owner}-{ppa_name}' + if f'{self.ident}.{BASE_FORMAT.value}' not in util.files: + new_file = SourceFile(name=self.ident) + new_file.format = BASE_FORMAT + self.file = new_file + util.files[str(self.file.path)] = self.file + else: + self.file = util.files[str(self.file.path)] + + self.file.add_source(self) + + self.name = self.ident + self.uris = [f'{BASE_URL}/{ppa_owner}/{ppa_name}/{BASE_DIST}'] + self.suites = [util.DISTRO_CODENAME] + self.components = [BASE_COMPS] + + if meta or key: + self.ppa = get_info_from_lp(ppa_owner, ppa_name) + self.displayname = self.ppa.displayname + self.description = self.ppa.description + + if self.ppa and meta: + self.name = self.ppa.displayname + + if self.ppa and key: + repo_key = SourceKey(name=self.ident) + if str(repo_key.path) not in util.keys: + repo_key.load_key_data(fingerprint=self.ppa.fingerprint) + util.keys[str(repo_key.path)] = repo_key + self.key:SourceKey = repo_key + else: + self.key = util.keys[repo_key.path] + self.signed_by = str(self.key.path) + + self.enabled = True + + @property + def displayname(self) -> str: + """The name of the PPA provided by launchpad""" + if self._displayname: + return self._displayname + if self.ppa: + self._displayname = self.ppa.displayname + return self._displayname + + @displayname.setter + def displayname(self, displayname) -> None: + """Cache this for use without hitting LP""" + self._displayname = displayname + + @property + def description(self) -> str: + """The description of the PPA provided by Launchpad""" + if self._description: + return self._description + if self.ppa: + self._description = self.ppa.description + return self._description + + @description.setter + def description(self, desc) -> None: + """Cache this for use without hitting LP""" + self._description = desc + +class PPA: + """ An object to fetch data from PPAs. + + Portions of this class were adapted from Software Properties + """ + + def __init__(self, teamname, ppaname): + self.teamname = teamname + self.ppaname = ppaname + self._lap = None + self._lpteam = None + self._lpppa = None + self._signing_key_data = None + self._fingerprint = None + + @property + def lap(self): + """ The Launchpad Object.""" + if not self._lap: + self._lap = Launchpad.login_anonymously( + f'{self.__module__}.{self.__class__.__name__}', + service_root='production', + version='devel' + ) + return self._lap + + @property + def lpteam(self): + """ The Launchpad object for the PPA's owner.""" + if not self._lpteam: + try: + self._lpteam = self.lap.people(self.teamname) # type: ignore (This won't actually be unbound because of the property) + except NotFound as err: + msg = f'User/Team "{self.teamname}" not found' + raise SourceError(msg) from err + except Unauthorized as err: + msg = f'Invalid user/team name "{self.teamname}"' + raise SourceError(msg) from err + return self._lpteam + + @property + def lpppa(self): + """ The Launchpad object for the PPA.""" + if not self._lpppa: + try: + self._lpppa = self.lpteam.getPPAByName(name=self.ppaname) + except NotFound as err: + msg = f'PPA "{self.teamname}/{self.ppaname}"" not found' + raise SourceError(msg) from err + except BadRequest as err: + msg = f'Invalid PPA name "{self.ppaname}"' + raise SourceError(msg) from err + return self._lpppa + + @property + def description(self) -> str: + """str: The description of the PPA.""" + return self.lpppa.description or '' + + @property + def displayname(self) -> str: + """ str: the fancy name of the PPA.""" + return self.lpppa.displayname or '' + + @property + def fingerprint(self): + """ str: the fingerprint of the signing key.""" + if not self._fingerprint: + self._fingerprint = self.lpppa.signing_key_fingerprint + return self._fingerprint + + +def get_info_from_lp(owner_name, ppa): + """ Attempt to get information on a PPA from launchpad over the internet. + + Arguments: + owner_name (str): The Launchpad user owning the PPA. + ppa (str): The name of the PPA + + Returns: + json: The PPA information as a JSON object. + """ + ppa = PPA(owner_name, ppa) + return ppa diff --git a/repolib/usr/lib/python3/dist-packages/repolib/source.py b/repolib/usr/lib/python3/dist-packages/repolib/source.py new file mode 100644 index 0000000..ba89573 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/source.py @@ -0,0 +1,945 @@ +#!/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 get_key_info(self, halt_errors: bool = False) -> dict: + """ Get a dictionary containing information for the signing key for + this source. + + Arguments: + halt_errors (bool): if there are errors or other unexpected data, + raise a SourceError exception. Otherwise, simply log a warning. + + Returns: dict + The dictionary from gnupg with key info. + """ + if self.key: + keys:list = self.key.gpg.list_keys() + if len(keys) > 1: + error_msg = ( + f'The keyring for {self.ident} contains {len(keys)} keys' + '. Check the keyring object for details about the keys.' + ) + if halt_errors: + raise SourceError(error_msg) + self.log.warning(error_msg) + return keys[0] + + return {} + + 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, + } \ No newline at end of file diff --git a/repolib/usr/lib/python3/dist-packages/repolib/system.py b/repolib/usr/lib/python3/dist-packages/repolib/system.py new file mode 100644 index 0000000..b22ef09 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/system.py @@ -0,0 +1,73 @@ +#!/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 . import util +from .file import SourceFile +from .source import Source +from .shortcuts import popdev, ppa + + +log = logging.getLogger(__name__) + +def load_all_sources() -> None: + """Loads all of the sources present on the system.""" + log.info('Loading all sources') + + util.sources.clear() + util.files.clear() + util.keys.clear() + util.errors.clear() + + sources_path = Path(util.SOURCES_DIR) + sources_files = sources_path.glob('*.sources') + legacy_files = sources_path.glob('*.list') + + for file in sources_files: + try: + sourcefile = SourceFile(name=file.stem) + log.debug('Loading %s', file) + sourcefile.load() + if file.name not in util.files: + util.files[file.name] = sourcefile + + except Exception as err: + util.errors[file.name] = err + + for file in legacy_files: + try: + sourcefile = SourceFile(name=file.stem) + sourcefile.load() + util.files[file.name] = sourcefile + except Exception as err: + util.errors[file.name] = err + + for f in util.files: + file = util.files[f] + for source in file.sources: + if source.ident in util.sources: + source.ident = f'{file.name}-{source.ident}' + source.file.save() + util.sources[source.ident] = source diff --git a/repolib/usr/lib/python3/dist-packages/repolib/unittest/__init__.py b/repolib/usr/lib/python3/dist-packages/repolib/unittest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_key.py b/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_key.py new file mode 100644 index 0000000..977324b --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_key.py @@ -0,0 +1,142 @@ +#!/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 unittest + +from ..key import SourceKey +from .. import util, system +from .. import set_testing + +# System76 Signing PubKey, for test import +KEY_DATA = ( + '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFlL+3MBEADdNM9Xy2t3EtKU1i3R1o' + '1OCgJqLiDm8OZZq47InYID8oAPKRjd\n0UDVJTrvfsB4oJH97VRi2hGv2xmc19OaFE/NsQBZW/' + '7/3ypLr8eyaNgvscsmG/WN\ncM1cbMZtwd1b0JOr3bNTzp6WKRI3jo9uRw7duM8FwPjKm76Lbo' + 'DQbAR+4Szm3O8x\n/om8Gs1MRPUkY2dVz5KzednFLHwy7qnUXR3WRB5K1L9EBZkFDDNqnyViUI' + 'rE4bTm\nBC9mTg/Xfw/QXUFYz3t/YTYduAU0o1q2yei+8tVAJKh7H9t3PrQ95l3RUUcaAvba\n' + 'A9zlCrI8fonpxu7eSpkqzT4uCkfxdLVwittl1DumKTEkSXDQ5txY21igbSZZQwBA\nZf9MnFhJ' + 'fPsEIq2YHRc1FBcQxiAIpnGizv7FgYY5FxmZQ7592dMQOZ00h+lDSQug\nNMxloHCogaXR038u' + 'IKGTQnQEVcT46FtTRkLMSvbigy+RVSchdu9MEBBPgD3vSv53\nNEobXsLiZ9hF6Hk7XI2WxP5j' + '1zWTPmzxvf9NDOWz2Sw9Z+ilf252LXoxZQaMngp8\nXL32uvw7q+mjB6F1W/qpe3b32uu7eGNr' + 'DWJ5veE808hpXXj803TllmRUfMGUrtY9\nk7uUTQQWtrJ5uZ0QmsTk1oJHCPIUjjuiNtQfq28+' + 'bfg8FEJ/F1N1mB0IvwARAQAB\ntCxQb3AgT1MgKElTTyBTaWduaW5nIEtleSkgPGluZm9Ac3lz' + 'dGVtNzYuY29tPokC\nNwQTAQIAIgUCWUv7cwIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AA' + 'CgkQIE3Y\nrsM6ev8kXw/4p/8fOH8wM59ZoU0t1+fv3O8dYaDdTVTVIEno9snrsx5A5tbMu59r' + '\nHoBaxGenv/PB0l8yANhRX+HVmU/l0Sj0lTlEkYzgH/IT2Ne60s1ETgI7DlgSuYyP\nH8wq61' + '85+2DyE2+R/XcXGq0I++QUq1Y6rS+B4KIyYcgpJotcVNFaZiwuJZE31uLg\nkVMZrm1oObHear' + '7P2JQTbgsENMZDJEhQBCGKVdnAfVdKUeUrd07syr0cDe3kwY9o\ncNc00bhIh23cLTJE2omok9' + 'yCsXoeFJlPMyZw8WvEa5oaYWzP4Yw7nF8/27JTzZ70\nDjK2D2xoTkr0cP87LtZulS6FC3lxLu' + 'Z6hSaxsqoBH8Dd1uyYVEzLDsIRMtSHsXk+\n3kLrr1p7/7/vjGShlYkbLtP4jWnlHc6vSxIzm/' + 'MQmQMCfjeo3QH7GGw88mYtXngQ\n/Zna6wz0oL6pGM/4t90SCxTxRqCnoxMxzkcpt9n42bj79g' + 'rESOMH4wm3ExfuPk7I\nDtY+SqzIq0QvoPbC3XJLusWVgwUsRF2FpTTRTHEiWEMjWDKDVEyT4K' + '1k1k3f/gi2\n6LdtXwqDwzUvJJU5HYwVFywt+0jt5F0ZlTlPizz3iHw4gMLOielRShl+gZrU2U' + '0O\naj1Hyts9LymEKMUvRQGwMqCZcXo6sGjs59tTsfeGX16PTOyBri8eoLkCDQRZS/tz\nARAA' + 'pD9IWm4zS1AuBcOTuvh1E/ciKHGIUtW3JftD9ah8loEeckakgG5Xn9he1X6J\nyxPULpsptcCC' + 'cKXlw853ZQK9PLQJX6moWLH+qf2Zo3UAn/YEsWk+KsHoxPXHNUds\nu/j6UKkqEk8c7H92hUo8' + 'aWghO3p4HDVJ9KmGtueQ3jOv8Qun7Eh9cIo0A59cKmMv\njKUiYHLIJw8bkveQ8rVPul1ZHn56' + 'ORiBi58vm3tzjI4UWHQMjiKxXT6H5eG/f5K6\nuaK8lljh6n6jhdnQCpBcdtSIbhE/6YRv2+Ig' + 'L+BRssvprBtx4/sBwKjNNqzWPeGy\nUDHMiF88ETYqZ8DfukQ/e5XuaxjU41g/F8cw8BeVTBMv' + 'eb1YTyOoWcWvTL+hoBfS\nqYc/lvDHmmJ7/IgeMvUE6KoByP4ub5wX52mJTqgMC4GMhA04BC60' + 'B+NfVAXLh2pa\nTRJAHoWTDswOxbR6q9zPEFGZzV04B9Y96EavwMpT5IzG2fOPBwvdT0EDnt+v' + 'Q/iB\nc9O7CvkRTROAV+RoNCLY2XU8yNc/XxuI66PCE4Q96jW4uDzHvi6sPW/glsfRi2NT\nRW' + 'CO15KMVf0aypXeBpSbHIXIYGdXRQRpw980IW6PrElPpqZ5/DGbkXei5CuruF2R\nmltuu3MqYQ' + 'jcUvP9T7s0e5GAFgQFrR/8q29nVULq8IF4vzUAEQEAAYkCHwQYAQIA\nCQUCWUv7cwIbDAAKCR' + 'AgTdiuwzp6/wTGD/9Co4gEmTTOW++FneMMJo5K4WqeWVRg\ng1q5+yoVqgWq3k6lLsEC5kxR30' + '5BAAcvXo9XPKdo62ySYmhIFOpIz/TkeTUxDZaw\nsLtcBxXUME2L5j/1od1V9lxecUvLAgA11o' + '5Kb8TMKn5ZcmGhadtTLslWQcYsKqhw\nLaYQRlcxLDHYT8DXFkHgDhUMMbpt07dU5v5lIjgtGN' + 'HRhdS7/lCmSWOBtYapwpAH\nGYSmahN0zO36VHzOB5uwFue0tSoQiBEvLrCV/8ZtT2S5NkXaSm' + 'isz6B5Vr6DRtWI\nOamW5pMbSL8WQNQ99Kik05ctERjv2NgxI4JQo/a4KKthRrT4JlixXmrfJD' + 'uPyDPp\nRuTu1Elo6snoqWKQNf1sEPKvcv7EviNxBOhbTKivWrJXMnbOme7+UlNLcq7VAFp3\n' + 'x5hxk/ap0WqH/hs7+8jMBC8nS402MoM7EyLS0++kbOuEL/Prf3+JxFRqIu5Df77J\n+bUmTtKI' + 'CV43ikiVWmnP5OuJj2JPSOTR+rLxAQYpyHmo7HKXE63FbH1FVLgsT88+\nEW6VtI01I7EYmKQX' + 'EqQo52yfeHKDrQjGNVBWMKcXj0SVU+QQ1Ue/4yLwA+74VD2d\nfOyJI22NfTI+3SMAsMQ8L+WV' + 'QI+58bu7+iEqoEfHCXikE8BtTbJAN4Oob1lrjfOe\n5utH/lMP9suRWw==\n=NL3f\n-----EN' + 'D PGP PUBLIC KEY BLOCK-----\n' +) + +class KeyTestCase(unittest.TestCase): + def setUp(self): + set_testing() + self.key_data = KEY_DATA + self.keys_dir = util.KEYS_DIR + self.key_id = '204DD8AEC33A7AFF' + self.key_uids = ['Pop OS (ISO Signing Key) '] + self.key_length = '4096' + self.key_date = '1498151795' + + def test_import_ascii(self): + key = SourceKey(name='popdev') + key.load_key_data(ascii=self.key_data) + key_dict = key.gpg.list_keys()[0] + key_path = self.keys_dir / 'popdev-archive-keyring.gpg' + + self.assertEqual(key.path, key_path) + self.assertEqual(len(key.gpg.list_keys()), 1) + self.assertEqual(key_dict['keyid'], self.key_id) + self.assertEqual(key_dict['uids'], self.key_uids) + self.assertEqual(key_dict['length'], self.key_length) + self.assertEqual(key_dict['date'], self.key_date) + + def test_key_save_load(self): + print(self.keys_dir) + key_path = self.keys_dir / 'popdev-archive-keyring.gpg' + if key_path.exists(): + key_path.unlink() + + self.assertFalse(key_path.exists()) + key_save = SourceKey(name='popdev') + key_save.load_key_data(ascii=self.key_data) + key_save.save_gpg() + + self.assertTrue(key_save.path.exists()) + + key_load = SourceKey() + key_load.reset_path(name='popdev') + key_dict = key_load.gpg.list_keys()[0] + + self.assertEqual(key_load.path, key_path) + self.assertEqual(len(key_load.gpg.list_keys()), 1) + self.assertEqual(key_dict['keyid'], self.key_id) + self.assertEqual(key_dict['uids'], self.key_uids) + self.assertEqual(key_dict['length'], self.key_length) + self.assertEqual(key_dict['date'], self.key_date) + + def test_delete_key(self): + key_path = self.keys_dir / 'popdev-archive-keyring.gpg' + if key_path.exists(): + key_path.unlink() + + self.assertFalse(key_path.exists()) + + self.assertFalse(key_path.exists()) + key_save = SourceKey(name='popdev') + key_save.load_key_data(ascii=self.key_data) + + key_save.save_gpg() + + self.assertTrue(key_save.path.exists()) + + key_load = SourceKey() + key_load.reset_path(name='popdev') + key_load.delete_key() + + self.assertFalse(key_load.path.exists()) diff --git a/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_parsedeb.py b/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_parsedeb.py new file mode 100644 index 0000000..0a975a0 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_parsedeb.py @@ -0,0 +1,188 @@ +#!/usr/bin/python3 + +""" +Copyright (c) 2019-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 . + +This is a library for parsing deb lines into deb822-format data. +""" + +import unittest + +from ..source import Source +from .. import util + +class DebTestCase(unittest.TestCase): + def test_normal_source(self): + source = Source() + source.load_from_data([ + 'deb http://example.com/ suite main' + ]) + source.generate_default_ident() + self.assertEqual(source.types, [util.SourceType.BINARY]) + self.assertTrue(source.enabled.get_bool()) + self.assertEqual(source.uris, ['http://example.com/']) + self.assertEqual(source.suites, ['suite']) + self.assertEqual(source.components, ['main']) + self.assertEqual(source.ident, 'example-com-binary') + + def test_source_with_multiple_components(self): + source = Source() + source.load_from_data([ + 'deb http://example.com/ suite main nonfree' + ]) + source.generate_default_ident() + self.assertEqual(source.suites, ['suite']) + self.assertEqual(source.components, ['main', 'nonfree']) + + def test_source_with_option(self): + source = Source() + source.load_from_data([ + 'deb [ arch=amd64 ] http://example.com/ suite main' + ]) + source.generate_default_ident() + self.assertEqual(source.uris, ['http://example.com/']) + self.assertEqual(source.architectures, 'amd64') + + def test_source_uri_with_brackets(self): + source = Source() + source.load_from_data([ + 'deb http://example.com/[release]/ubuntu suite main' + ]) + source.generate_default_ident() + self.assertEqual(source.uris, ['http://example.com/[release]/ubuntu']) + + def test_source_options_with_colons(self): + source = Source() + source.load_from_data([ + 'deb [ arch=arm:2 ] http://example.com/ suite main' + ]) + source.generate_default_ident() + self.assertEqual(source.uris, ['http://example.com/']) + self.assertEqual(source.architectures, 'arm:2') + + def test_source_with_multiple_option_values(self): + source = Source() + source.load_from_data([ + 'deb [ arch=armel,amd64 ] http://example.com/ suite main' + ]) + source.generate_default_ident() + self.assertEqual(source.uris, ['http://example.com/']) + self.assertEqual(source.architectures, 'armel amd64') + + def test_source_with_multiple_options(self): + source = Source() + source.load_from_data([ + 'deb [ arch=amd64 lang=en_US ] http://example.com/ suite main' + ]) + source.generate_default_ident() + self.assertEqual(source.uris, ['http://example.com/']) + self.assertEqual(source.architectures, 'amd64') + self.assertEqual(source.languages, 'en_US') + + def test_source_with_multiple_options_with_multiple_values(self): + source = Source() + source.load_from_data([ + 'deb [ arch=amd64,armel lang=en_US,en_CA ] ' + 'http://example.com/ suite main' + ]) + source.generate_default_ident() + self.assertEqual(source.uris, ['http://example.com/']) + self.assertEqual(source.architectures, 'amd64 armel') + self.assertEqual(source.languages, 'en_US en_CA') + + def test_source_uri_with_brackets_and_options(self): + source = Source() + source.load_from_data([ + 'deb [ arch=amd64 lang=en_US,en_CA ] ' + 'http://example][.com/[release]/ubuntu suite main' + ]) + source.generate_default_ident() + self.assertEqual(source.uris, ['http://example][.com/[release]/ubuntu']) + self.assertEqual(source.architectures, 'amd64') + self.assertEqual(source.languages, 'en_US en_CA') + + def test_source_uri_with_brackets_and_options_with_colons(self): + source = Source() + source.load_from_data([ + 'deb [ arch=amd64,arm:2 lang=en_US,en_CA ] ' + 'http://example][.com/[release]/ubuntu suite main' + ]) + source.generate_default_ident() + self.assertEqual(source.uris, ['http://example][.com/[release]/ubuntu']) + self.assertEqual(source.architectures, 'amd64 arm:2') + self.assertEqual(source.languages, 'en_US en_CA') + + def test_worst_case_sourcenario(self): + source = Source() + source.load_from_data([ + 'deb [ arch=amd64,arm:2,arm][ lang=en_US,en_CA ] ' + 'http://example][.com/[release:good]/ubuntu suite main restricted ' + 'nonfree not-a-component' + ]) + source.generate_default_ident() + self.assertEqual(source.uris, ['http://example][.com/[release:good]/ubuntu']) + self.assertEqual(source.suites, ['suite']) + self.assertEqual(source.components, [ + 'main', 'restricted', 'nonfree', 'not-a-component' + ]) + source.generate_default_ident() + self.assertEqual(source.architectures, 'amd64 arm:2 arm][') + self.assertEqual(source.languages, 'en_US en_CA') + + def test_source_code_source(self): + source = Source() + source.load_from_data([ + 'deb-src http://example.com/ suite main' + ]) + source.generate_default_ident() + self.assertEqual(source.types, [util.SourceType.SOURCECODE]) + + def test_disabled_source(self): + source = Source() + source.load_from_data([ + '# deb http://example.com/ suite main' + ]) + source.generate_default_ident() + self.assertFalse(source.enabled.get_bool()) + + def test_disabled_source_without_space(self): + source = Source() + source.load_from_data([ + '#deb http://example.com/ suite main' + ]) + source.generate_default_ident() + self.assertFalse(source.enabled.get_bool()) + + def test_source_with_trailing_comment(self): + source = Source() + source.load_from_data([ + 'deb http://example.com/ suite main # This is a comment' + ]) + source.generate_default_ident() + self.assertEqual(source.suites, ['suite']) + self.assertEqual(source.components, ['main']) + + def test_disabled_source_with_trailing_comment(self): + source = Source() + source.load_from_data([ + '# deb http://example.com/ suite main # This is a comment' + ]) + source.generate_default_ident() + self.assertEqual(source.suites, ['suite']) + self.assertEqual(source.components, ['main']) diff --git a/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_popdev.py b/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_popdev.py new file mode 100644 index 0000000..8207b09 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_popdev.py @@ -0,0 +1,43 @@ +#!/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 unittest + +from ..shortcuts import popdev +from .. import util + +class PopdevTestCase(unittest.TestCase): + + def test_ppa(self): + source = popdev.PopdevSource() + + # Verification data + uris_test = ['http://apt.pop-os.org/staging/master'] + signed_test = '/usr/share/keyrings/popdev-archive-keyring.gpg' + source.load_from_shortcut(shortcut='popdev:master') + + self.assertEqual(source.uris, uris_test) + self.assertEqual(source.ident, 'popdev-master') + self.assertEqual(source.suites, [util.DISTRO_CODENAME]) + self.assertEqual(source.components, ['main']) + self.assertEqual(source.types, [util.SourceType.BINARY]) + self.assertTrue(source.signed_by.endswith(signed_test)) \ No newline at end of file diff --git a/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_ppa.py b/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_ppa.py new file mode 100644 index 0000000..9b346cf --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_ppa.py @@ -0,0 +1,42 @@ +#!/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 unittest + +from ..shortcuts import ppa +from .. import util + +class PPATestCase(unittest.TestCase): + + def test_ppa(self): + source = ppa.PPASource() + + # Verification data + uris_test = ['http://ppa.launchpad.net/system76/pop/ubuntu'] + signed_test = '/usr/share/keyrings/ppa-system76-pop-archive-keyring.gpg' + source.load_from_shortcut(shortcut='ppa:system76/pop', meta=False, key=False) + + self.assertEqual(source.uris, uris_test) + self.assertEqual(source.ident, 'ppa-system76-pop') + self.assertEqual(source.suites, [util.DISTRO_CODENAME]) + self.assertEqual(source.components, ['main']) + self.assertEqual(source.types, [util.SourceType.BINARY]) diff --git a/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_source.py b/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_source.py new file mode 100644 index 0000000..ae7f38e --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/unittest/test_source.py @@ -0,0 +1,245 @@ +#!/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 unittest + +from .. import file, util, source + +class SourceTestCase(unittest.TestCase): + def setUp(self): + util.set_testing() + self.source = source.Source() + self.source.ident = 'test' + self.source.name = 'Test Source' + self.source.enabled = True + self.source.types = [util.SourceType.BINARY, util.SourceType.SOURCECODE] + self.source.uris = ['http://example.com/ubuntu', 'http://example.com/mirror'] + self.source.suites = ['suite', 'suite-updates'] + self.source.components = ['main', 'contrib', 'nonfree'] + self.source.architectures = 'amd64 armel' + self.source.languages = 'en_US en_CA' + self.file = file.SourceFile(name=self.source.ident) + self.file.add_source(self.source) + self.source.file = self.file + + self.source_legacy = source.Source() + self.source_legacy.ident = 'test-legacy' + self.source_legacy.name = 'Test Legacy Source' + self.source_legacy.enabled = True + self.source_legacy.types = [util.SourceType.BINARY] + self.source_legacy.uris = ['http://example.com/ubuntu'] + self.source_legacy.suites = ['suite'] + self.source_legacy.components = ['main', 'contrib', 'nonfree'] + self.source_legacy.architectures = 'amd64 armel' + self.source_legacy.languages = 'en_US en_CA' + self.source_legacy.file = file.SourceFile(name=self.source_legacy.ident) + self.source_legacy.file.format = util.SourceFormat.LEGACY + + + def test_default_source_data(self): + self.assertEqual(self.source.name, 'Test Source') + self.assertTrue(self.source.enabled.get_bool()) + self.assertEqual( + self.source.types, + [util.SourceType.BINARY, util.SourceType.SOURCECODE] + ) + self.assertTrue(self.source.sourcecode_enabled) + self.assertEqual( + self.source.uris, + ['http://example.com/ubuntu', 'http://example.com/mirror'] + ) + self.assertEqual( + self.source.suites, + ['suite', 'suite-updates'] + ) + self.assertEqual( + self.source.components, + ['main', 'contrib', 'nonfree'] + ) + self.assertEqual(self.source.architectures, 'amd64 armel') + self.assertEqual(self.source.languages, 'en_US en_CA') + self.assertEqual(self.source.file.path.name, 'test.sources') + + def test_output_822(self): + source_string = ( + 'X-Repolib-ID: test\n' + 'X-Repolib-Name: Test Source\n' + 'Enabled: yes\n' + 'Types: deb deb-src\n' + 'URIs: http://example.com/ubuntu http://example.com/mirror\n' + 'Suites: suite suite-updates\n' + 'Components: main contrib nonfree\n' + 'Architectures: amd64 armel\n' + 'Languages: en_US en_CA\n' + ) + legacy_source_string = ( + 'X-Repolib-ID: test-legacy\n' + 'X-Repolib-Name: Test Legacy Source\n' + 'Enabled: yes\n' + 'Types: deb\n' + 'URIs: http://example.com/ubuntu\n' + 'Suites: suite\n' + 'Components: main contrib nonfree\n' + 'Architectures: amd64 armel\n' + 'Languages: en_US en_CA\n' + ) + self.assertEqual(self.source.deb822, source_string) + self.assertEqual(self.source_legacy.deb822, legacy_source_string) + + def test_output_ui(self): + source_string = ( + 'test:\n' + 'Name: Test Source\n' + 'Enabled: yes\n' + 'Types: deb deb-src\n' + 'URIs: http://example.com/ubuntu http://example.com/mirror\n' + 'Suites: suite suite-updates\n' + 'Components: main contrib nonfree\n' + 'Architectures: amd64 armel\n' + 'Languages: en_US en_CA\n' + '' + ) + legacy_source_string = ( + 'test-legacy:\n' + 'Name: Test Legacy Source\n' + 'Enabled: yes\n' + 'Types: deb\n' + 'URIs: http://example.com/ubuntu\n' + 'Suites: suite\n' + 'Components: main contrib nonfree\n' + 'Architectures: amd64 armel\n' + 'Languages: en_US en_CA\n' + ) + self.assertEqual(self.source.ui, source_string) + self.assertEqual(self.source_legacy.ui, legacy_source_string) + + def test_output_legacy(self): + source_string = ( + 'deb [arch=amd64,armel lang=en_US,en_CA] http://example.com/ubuntu suite main contrib nonfree ## X-Repolib-Name: Test Legacy Source # X-Repolib-ID: test-legacy' + ) + self.assertEqual(self.source_legacy.legacy, source_string) + + def test_enabled(self): + self.source.enabled = False + self.assertFalse(self.source.enabled.get_bool()) + + def test_sourcecode_enabled(self): + self.source.sourcecode_enabled = False + self.assertEqual(self.source.types, [util.SourceType.BINARY]) + + def test_dict_access(self): + self.assertEqual(self.source['X-Repolib-ID'], 'test') + self.assertEqual(self.source['X-Repolib-Name'], 'Test Source') + self.assertEqual(self.source['Enabled'], 'yes') + self.assertEqual(self.source['Enabled'], 'yes') + self.assertEqual(self.source['Types'], 'deb deb-src') + self.assertEqual(self.source['URIs'], 'http://example.com/ubuntu http://example.com/mirror') + self.assertEqual(self.source['Suites'], 'suite suite-updates') + self.assertEqual(self.source['Components'], 'main contrib nonfree') + self.assertEqual(self.source['Architectures'], 'amd64 armel') + self.assertEqual(self.source['Languages'], 'en_US en_CA') + + def test_load(self): + load_source = source.Source() + load_source.load_from_data([ + 'X-Repolib-ID: load-test', + 'X-Repolib-Name: Test Source Loading', + 'Enabled: yes', + 'Types: deb', + 'URIs: http://example.com/ubuntu http://example.com/mirror', + 'Suites: suite suite-updates', + 'Components: main contrib nonfree', + 'Architectures: amd64 armel', + 'Languages: en_US en_CA', + ]) + + self.assertEqual(load_source.ident, 'load-test') + self.assertEqual(load_source.name, 'Test Source Loading') + self.assertTrue(load_source.enabled.get_bool()) + self.assertEqual( + load_source.types, + [util.SourceType.BINARY] + ) + self.assertEqual( + load_source.uris, + ['http://example.com/ubuntu', 'http://example.com/mirror'] + ) + self.assertEqual( + load_source.suites, + ['suite', 'suite-updates'] + ) + self.assertEqual( + load_source.components, + ['main', 'contrib', 'nonfree'] + ) + self.assertEqual(load_source.architectures, 'amd64 armel') + self.assertEqual(load_source.languages, 'en_US en_CA') + + load_legacy_source = source.Source() + load_legacy_source.load_from_data( + ['deb [arch=amd64,armel lang=en_US,en_CA] http://example.com/ubuntu suite main contrib nonfree ## X-Repolib-Name: Test Legacy Source Loading # X-Repolib-ID: test-load-legacy'] + ) + + self.assertEqual(load_legacy_source.ident, 'test-load-legacy') + self.assertEqual(load_legacy_source.name, 'Test Legacy Source Loading') + self.assertTrue(load_legacy_source.enabled.get_bool()) + self.assertEqual( + load_legacy_source.types, + [util.SourceType.BINARY] + ) + self.assertEqual( + load_legacy_source.uris, + ['http://example.com/ubuntu'] + ) + self.assertEqual( + load_legacy_source.suites, + ['suite'] + ) + self.assertEqual( + load_legacy_source.components, + ['main', 'contrib', 'nonfree'] + ) + self.assertEqual(load_legacy_source.architectures, 'amd64 armel') + self.assertEqual(load_legacy_source.languages, 'en_US en_CA') + + def test_save_load(self): + self.source.file.save() + load_source_file = file.SourceFile(name='test') + load_source_file.load() + self.assertGreater(len(load_source_file.sources), 0) + self.assertGreater( + len(load_source_file.contents), len(load_source_file.sources) + ) + load_source = load_source_file.sources[0] + + self.assertEqual(load_source.ident, self.source.ident) + self.assertEqual(load_source.name, self.source.name) + self.assertEqual(load_source.enabled, self.source.enabled) + self.assertEqual(load_source.types, self.source.types) + self.assertEqual(load_source.sourcecode_enabled, self.source.sourcecode_enabled) + self.assertEqual(load_source.uris, self.source.uris) + self.assertEqual(load_source.suites, self.source.suites) + self.assertEqual(load_source.components, self.source.components) + self.assertEqual(load_source.architectures, self.source.architectures) + self.assertEqual(load_source.languages, self.source.languages) + self.assertEqual(load_source.file.name, self.source.file.name) + \ No newline at end of file diff --git a/repolib/usr/lib/python3/dist-packages/repolib/util.py b/repolib/usr/lib/python3/dist-packages/repolib/util.py new file mode 100644 index 0000000..cb303a9 --- /dev/null +++ b/repolib/usr/lib/python3/dist-packages/repolib/util.py @@ -0,0 +1,455 @@ +#!/usr/bin/python3 + +""" +Copyright (c) 2019-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 atexit +import logging +import re +import tempfile + +from enum import Enum +from pathlib import Path +from urllib.parse import urlparse +from urllib import request, error + +import dbus + +SOURCES_DIR = Path('/etc/apt/sources.list.d') +KEYS_DIR = Path('/etc/apt/keyrings/') +TESTING = False +KEYSERVER_QUERY_URL = 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x' + +log = logging.getLogger(__name__) + +class RepoError(Exception): + """ Exception from this module.""" + + 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 + +try: + import distro + DISTRO_CODENAME = distro.codename() +except ImportError: + DISTRO_CODENAME = 'linux' + +class SourceFormat(Enum): + """Enum of SourceFile Formats""" + DEFAULT = "sources" + LEGACY = "list" + +class SourceType(Enum): + """Enum of repository types""" + BINARY = 'deb' + SOURCECODE = 'deb-src' + + def ident(self) -> str: + """Used for getting a version of the format for idents""" + ident = f'{self.value}' + ident = ident.replace('deb-src', 'source') + ident = ident.replace('deb', 'binary') + return ident + +class AptSourceEnabled(Enum): + """ Helper Enum to translate between bool data and the Deb822 format. """ + TRUE = 'yes' + FALSE = 'no' + + def get_bool(self): + """ Return a bool based on the value. """ + # pylint: disable=comparison-with-callable + # This doesnt seem to actually be a callable in this case. + if self.value == "yes": + return True + + return False + +valid_keys = [ + 'X-Repolib-Name:', + 'X-Repolib-ID:', + 'X-Repolib-Default-Mirror:', + 'X-Repolib-Comment', + 'X-Repolib-Prefs', + 'Enabled:', + 'Types:', + 'URIs:', + 'Suites:', + 'Components:', + 'Architectures:', + 'Languages:', + 'Targets:', + 'PDiffs:', + 'By-Hash:', + 'Allow-Insecure:', + 'Allow-Weak:', + 'Allow-Downgrade-To-Insecure:', + 'Trusted:', + 'Signed-By:', + 'Check-Valid-Until:', + 'Valid-Until-Min:', + 'Valid-Until-Max:', +] + +output_skip_keys = [ + 'X-Repolib-Prefs', + 'X-Repolib-ID', +] + +options_inmap = { + 'arch': 'Architectures', + 'lang': 'Languages', + 'target': 'Targets', + 'pdiffs': 'PDiffs', + 'by-hash': 'By-Hash', + 'allow-insecure': 'Allow-Insecure', + 'allow-weak': 'Allow-Weak', + 'allow-downgrade-to-insecure': 'Allow-Downgrade-To-Insecure', + 'trusted': 'Trusted', + 'signed-by': 'Signed-By', + 'check-valid-until': 'Check-Valid-Until', + 'valid-until-min': 'Valid-Until-Min', + 'valid-until-max': 'Valid-Until-Max' +} + +options_outmap = { + 'Architectures': 'arch', + 'Languages': 'lang', + 'Targets': 'target', + 'PDiffs': 'pdiffs', + 'By-Hash': 'by-hash', + 'Allow-Insecure': 'allow-insecure', + 'Allow-Weak': 'allow-weak', + 'Allow-Downgrade-To-Insecure': 'allow-downgrade-to-insecure', + 'Trusted': 'trusted', + 'Signed-By': 'signed-by', + 'Check-Valid-Until': 'check-valid-until', + 'Valid-Until-Min': 'valid-until-min', + 'Valid-Until-Max': 'valid-until-max' +} + +true_values = [ + True, + 'True', + 'true', + 'Yes', + 'yes', + 'YES', + 'y', + 'Y', + AptSourceEnabled.TRUE, + 1 +] + +keys_map = { + 'X-Repolib-Name: ': 'Name: ', + 'X-Repolib-ID: ': 'Ident: ', + 'X-Repolib-Comments: ': 'Comments: ', + 'X-Repolib-Default-Mirror: ': 'Default Mirror: ', +} + +PRETTY_PRINT = '\n ' + +_KEYS_TEMPDIR = tempfile.TemporaryDirectory() +TEMP_DIR = Path(_KEYS_TEMPDIR.name) + +options_re = re.compile(r'[^@.+]\[([^[]+.+)\]\ ') +uri_re = re.compile(r'\w+:(\/?\/?)[^\s]+') + +CLEAN_CHARS = { + 33: None, + 64: 45, + 35: 45, + 36: 45, + 37: 45, + 94: 45, + 38: 45, + 42: 45, + 41: None, + 40: None, + 43: 45, + 61: 45, + 91: None, + 92: None, + 93: None, + 123: None, + 125: None, + 124: 95, + 63: None, + 47: 95, + 46: 45, + 60: 95, + 62: 95, + 44: 95, + 96: None, + 126: None, + 32: 95, + 58: None, + 59: None, +} + +sources:dict = {} +files:dict = {} +keys:dict = {} +errors:dict = {} + + +def scrub_filename(name: str = '') -> str: + """ Clean up a string intended for a filename. + + Arguments: + name (str): The prospective name to scrub. + + Returns: str + The cleaned-up name. + """ + return name.translate(CLEAN_CHARS) + +def set_testing(testing:bool=True) -> None: + """Sets Repolib in testing mode where changes will not be saved. + + Arguments: + testing(bool): Whether testing mode should be enabled or disabled + (Defaul: True) + """ + global KEYS_DIR + global SOURCES_DIR + + testing_tempdir = tempfile.TemporaryDirectory() + + if not testing: + KEYS_DIR = '/usr/share/keyrings' + SOURCES_DIR = '/etc/apt/sources.list.d' + return + + testing_root = Path(testing_tempdir.name) + KEYS_DIR = testing_root / 'usr' / 'share' / 'keyrings' + SOURCES_DIR = testing_root / 'etc' / 'apt' / 'sources.list.d' + + +def _cleanup_temsps() -> None: + """Clean up our tempdir""" + _KEYS_TEMPDIR.cleanup() + # _TESTING_TEMPDIR.cleanup() + +atexit.register(_cleanup_temsps) + +def dbus_quit(): + bus = dbus.SystemBus() + privileged_object = bus.get_object('org.pop_os.repolib', '/Repo') + privileged_object.exit() + +def compare_sources(source1, source2, excl_keys:list) -> bool: + """Compare two sources based on arbitrary criteria. + + This looks at a given list of keys, and if the given keys between the two + given sources are identical, returns True. + + Arguments: + source1, source2(Source): The two sources to compare + excl_keys([str]): Any keys to exclude from the comparison + + Returns: bool + `True` if the sources are identical, otherwise `False`. + """ + for key in source1: + if key in excl_keys: + continue + if key in source2: + if source1[key] != source2[key]: + return False + else: + continue + else: + return False + for key in source2: + if key in excl_keys: + continue + if key in source1: + if source1[key] != source2[key]: + return False + else: + continue + else: + return False + return True + +def find_differences_sources(source1, source2, excl_keys:list) -> dict: + """Find key-value pairs which differ between two sources. + + Arguments: + source1, source2(Source): The two sources to compare + excl_keys([str]): Any keys to exclude from the comparison + + Returns: dict{'key': ('source1[key]','source2[key]')} + The dictionary of different keys, with the key values from each source. + """ + differing_keys:dict = {} + + for key in source1: + if key in excl_keys: + continue + if key in source2: + if source1[key] == source2[key]: + continue + differing_keys[key] = (source1[key], source2[key]) + differing_keys[key] = (source1[key], '') + for key in source2: + if key in excl_keys: + continue + if key in source1: + if source1[key] == source2[key]: + continue + differing_keys[key] = ('', source2[key]) + + return differing_keys + +def combine_sources(source1, source2) -> None: + """Combine the data in two sources into one. + + Arguments: + source1(Source): The source to be merged into + source2(Source): The source to merge from + """ + for key in source1: + if key in ('X-Repolib-Name', 'X-Repolib-ID', 'Enabled', 'Types'): + continue + if key in source2: + source1[key] += f' {source2[key]}' + for key in source2: + if key in ('X-Repolib-Name', 'X-Repolib-ID', 'Enabled', 'Types'): + continue + if key in source1: + source1[key] += f' {source2[key]}' + + # Need to deduplicate the list + for key in source1: + vals = source1[key].strip().split() + newvals = [] + for val in vals: + if val not in newvals: + newvals.append(val) + source1[key] = ' '.join(newvals) + for key in source2: + vals = source2[key].strip().split() + newvals = [] + for val in vals: + if val not in newvals: + newvals.append(val) + source2[key] = ' '.join(newvals) + + +def prettyprint_enable(enabled: bool = True) -> None: + """Easy helper to enable/disable pretty-printing for object reprs. + + Can also be used as an easy way to reset to defaults. + + Arguments: + enabled(bool): Whether or not Pretty Printing should be enabled + """ + global PRETTY_PRINT + if enabled: + PRETTY_PRINT = '\n ' + else: + PRETTY_PRINT = '' + +def url_validator(url): + """ Validate a url and tell if it's good or not. + + Arguments: + url (str): The URL to validate. + + Returns: + `True` if `url` is not malformed, otherwise `False`. + """ + try: + # pylint: disable=no-else-return,bare-except + # A) We want to return false if the URL doesn't contain those parts + # B) We need this to not throw any exceptions, regardless what they are + result = urlparse(url) + if not result.scheme: + return False + if result.scheme == 'x-repolib-name': + return False + if result.netloc: + # We need at least a scheme and a netlocation/hostname or... + return all([result.scheme, result.netloc]) + elif result.path: + # ...a scheme and a path (this allows file:/// URIs which are valid) + return all([result.scheme, result.path]) + return False + except: + return False + +def validate_debline(valid): + """ Basic checks to see if a given debline is valid or not. + + Arguments: + valid (str): The line to validate. + + Returns: + True if the line is valid, False otherwise. + """ + comment:bool = False + if valid.startswith('#'): + comment = True + valid = valid.replace('#', '') + valid = valid.strip() + + if valid.startswith("deb"): + words = valid.split() + for word in words: + if url_validator(word): + return True + + elif valid.startswith("ppa:"): + if "/" in valid: + return True + + else: + if valid.endswith('.flatpakrepo'): + return False + if len(valid.split()) == 1 and not comment: + return url_validator(valid) + return False + +def strip_hashes(line:str) -> str: + """ Strips the leading #'s from the given line. + + Arguments: + line (str): The line to strip. + + Returns: + (str): The input line without any leading/trailing hashes or + leading/trailing whitespace. + """ + while True: + line = line.strip('#') + line = line.strip() + if not line.startswith('#'): + break + + return line diff --git a/repolib/usr/lib/repolib/add-apt-repository b/repolib/usr/lib/repolib/add-apt-repository new file mode 100755 index 0000000..5c2f26c --- /dev/null +++ b/repolib/usr/lib/repolib/add-apt-repository @@ -0,0 +1,194 @@ +#!/usr/bin/python3 + +""" +Copyright (c) 2019-2020, 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 . +""" +#pylint: disable=invalid-name +# Pylint will complain about our module name not being snake_case, however this +# is a command rather than a python module, and thus this is correct anyway. + +import argparse +import os +import subprocess + +import repolib + +system_codename = repolib.util.DISTRO_CODENAME + +system_components = [ + 'main', + 'universe', + 'multiverse', + 'restricted' +] + +system_suites = [ + system_codename, + f'{system_codename}-updates', + f'{system_codename}-security', + f'{system_codename}-backports', + f'{system_codename}-proposed', + 'updates', + 'security', + 'backports', + 'proposed', +] + +def get_args(): + parser = argparse.ArgumentParser( + prog='add-apt-repository', + description=( + 'add-apt-repository is a script for adding apt sources.list entries.' + '\nThis command has been deprecated in favor of `apt-manage`. See ' + '`apt-manage --help` for more information.' + ) + ) + + parser.add_argument( + 'sourceline', + metavar='' + ) + + group = parser.add_mutually_exclusive_group() + + group.add_argument( + '-m', + '--massive-debug', + dest='debug', + action='store_true', + help='Print a lot of debug information to the command line' + ) + + group.add_argument( + '-r', + '--remove', + action='store_true', + help='remove repository from sources.list.d directory' + ) + + group.add_argument( + '-s', + '--enable-source', + dest='source', + action='store_true', + help='Allow downloading of source packages from the repository' + ) + + parser.add_argument( + '-y', + '--yes', + action='store_true', + help='Assum yes to all queries' + ) + + parser.add_argument( + '-n', + '--no-update', + dest='noupdate', + action='store_true', + help='Do not update package cache after adding' + ) + + parser.add_argument( + '-u', + '--update', + action='store_true', + help='Update package cache after adding (legacy option)' + ) + + parser.add_argument( + '-k', + '--keyserver', + metavar='KEYSERVER', + help='Legacy option, unused.' + ) + + return parser + +parser = get_args() +args = parser.parse_args() + +command = ['apt-manage'] + +if args.debug: + command.append('-bb') + +sourceline = args.sourceline +run = True +remove = False + +if sourceline in system_components: + command.append('modify') + command.append('system') + if not args.remove: + command.append('--add-component') + else: + command.append('--remove-component') + +elif sourceline in system_suites: + command.append('modify') + command.append('system') + if not args.remove: + command.append('--add-suite') + else: + command.append('--remove-suite') + +else: + + if args.source: + command.append('source') + + elif args.remove: + remove = True + command.append('remove') + + else: + command.append('add') + if not args.yes: + command.append('--expand') + +if not remove: + command.append(sourceline) +else: + sources = repolib.get_all_sources() + comp_source = repolib.DebLine(sourceline) + for source in sources: + if comp_source.uris[0] in source.uris: + name = str(source.filename.name) + name = name.replace(".list", "") + name = name.replace(".sources", "") + command.append(name) + +run = True + +if os.geteuid() != 0: + print('Error: must run as root') + run = False + +if run: + subprocess.run(command) + + if not args.noupdate: + subprocess.run(['apt', 'update']) + +print('NOTE: add-apt-repository is deprecated in Pop!_OS. Use this instead:') +print_command = command.copy() +if '--expand' in print_command: + print_command.remove('--expand') +print(' '.join(print_command)) diff --git a/repolib/usr/lib/repolib/service.py b/repolib/usr/lib/repolib/service.py new file mode 100755 index 0000000..b8d79dd --- /dev/null +++ b/repolib/usr/lib/repolib/service.py @@ -0,0 +1,316 @@ +#!/usr/bin/python3 +''' + Copyright 2020 Ian Santopietro (ian@system76.com) + + This file is part of Repolib. + + Repolib is free software: you can redistribute it and/or modify + it under the terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Repolib. If not, see . +''' +#pylint: skip-file + +import shutil +import subprocess + +import gi +from gi.repository import GObject, GLib + +from pathlib import Path +import dbus +import dbus.service +import dbus.mainloop.glib +import time + +import repolib + +class RepolibException(dbus.DBusException): + _dbus_error_name = 'org.pop_os.repolib.RepolibException' + +class PermissionDeniedByPolicy(dbus.DBusException): + _dbus_error_name = 'org.pop_os.repolib.PermissionDeniedByPolicy' + +class AptException(Exception): + pass + +class Repo(dbus.service.Object): + def __init__(self, conn=None, object_path=None, bus_name=None): + dbus.service.Object.__init__(self, conn, object_path, bus_name) + + # These are used by PolKit to check privileges + self.dbus_info = None + self.polkit = None + self.enforce_polkit = True + + try: + self.system_repo = repolib.SystemSource() + except: + self.system_repo = None + + self.source = None + self.sources_dir = Path('/etc/apt/sources.list.d') + self.keys_dir = Path('/etc/apt/trusted.gpg.d') + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='as', out_signature='', + sender_keyword='sender', connection_keyword='conn' + ) + def add_apt_signing_key(self, cmd, sender=None, conn=None): + self._check_polkit_privilege( + sender, conn, 'org.pop_os.repolib.modifysources' + ) + print(cmd) + key_path = str(cmd.pop(-1)) + with open(key_path, mode='wb') as keyfile: + try: + subprocess.run(cmd, check=True, stdout=keyfile) + except subprocess.CalledProcessError as e: + raise e + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='ss', out_signature='', + sender_keyword='sender', connection_keyword='conn' + ) + def install_signing_key(self, src, dest, sender=None, conn=None): + self._check_polkit_privilege( + sender, conn, 'org.pop_os.repolib.modifysources' + ) + shutil.copy2(src, dest) + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='s', out_signature='', + sender_keyword='sender', connection_keyword='conn' + ) + def delete_signing_key(self, src, sender=None, conn=None): + self._check_polkit_privilege( + sender, conn, 'org.pop_os.repolib.modifysources' + ) + key_path = Path(src) + key_path.unlink() + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='s', out_signature='', + sender_keyword='sender', connection_keyword='conn' + ) + def delete_prefs_file(self, src, sender=None, conn=None): + self._check_polkit_privilege( + sender, conn, 'org.pop_os.repolib.modifysources' + ) + prefs_path = Path(src) + prefs_path.unlink() + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='ss', out_signature='', + sender_keyword='sender', connection_keyword='conn' + ) + def output_prefs_to_disk(self, path, contents, sender=None, conn=None): + self._check_polkit_privilege( + sender, conn, 'org.pop_os.repolib.modifysources' + ) + full_path = Path(path) + with open(full_path, mode='w') as output_file: + output_file.write(contents) + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='ss', out_signature='', + sender_keyword='sender', connection_keyword='conn' + ) + def output_file_to_disk(self, filename, source, sender=None, conn=None): + self._check_polkit_privilege( + sender, conn, 'org.pop_os.repolib.modifysources' + ) + full_path = self.sources_dir / filename + with open(full_path, mode='w') as output_file: + output_file.write(source) + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='ss', out_signature='', + sender_keyword='sender', connection_keyword='conn' + ) + def backup_alt_file(self, alt_file, save_file, sender=None, conn=None): + self._check_polkit_privilege( + sender, conn, 'org.pop_os.repolib.modifysources' + ) + alt_path = self.sources_dir / alt_file + save_path = self.sources_dir / save_file + if alt_path.exists(): + alt_path.rename(save_path) + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='s', out_signature='', + sender_keyword='sender', connection_keyword='conn' + ) + def delete_source_file(self, filename, sender=None, conn=None): + self._check_polkit_privilege( + sender, conn, 'org.pop_os.repolib.modifysources' + ) + source_file = self.sources_dir / filename + source_file.unlink() + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='', out_signature='', + sender_keyword='sender', connection_keyword='conn' + ) + def exit(self, sender=None, conn=None): + mainloop.quit() + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='b', out_signature='b', + sender_keyword='sender', connection_keyword='conn' + ) + def set_system_source_code_enabled(self, enabled, sender=None, conn=None): + """ Enable or disable source code in the system source. + + Arguments: + enabled (bool): The new state to set, True = Enabled. + """ + self._check_polkit_privilege( + sender, conn, 'org.pop_os.repolib.modifysources' + ) + if self.system_repo: + self.system_repo.load_from_file() + new_types = [repolib.util.AptSourceType.BINARY] + if enabled: + new_types.append(repolib.util.AptSourceType.SOURCE) + self.system_repo.types = new_types + self.system_repo.save_to_disk() + return enabled + return False + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='sb', out_signature='b', + sender_keyword='sender', connection_keyword='conn' + ) + def set_system_comp_enabled(self, comp, enable, sender=None, conn=None): + """ Enable or disable a component in the system source. + + Arguments: + comp (str): the component to set + enable (bool): The new state to set, True = Enabled. + """ + self._check_polkit_privilege( + sender, conn, 'org.pop_os.repolib.modifysources' + ) + if self.system_repo: + self.system_repo.load_from_file() + self.system_repo.set_component_enabled(component=comp, enabled=enable) + self.system_repo.save_to_disk() + return True + return False + + @dbus.service.method( + "org.pop_os.repolib.Interface", + in_signature='sb', out_signature='b', + sender_keyword='sender', connection_keyword='conn' + ) + def set_system_suite_enabled(self, suite, enable, sender=None, conn=None): + """ Enable or disable a suite in the system source. + + Arguments: + suite (str): the suite to set + enable (bool): The new state to set, True = Enabled. + """ + self._check_polkit_privilege( + sender, conn, 'org.pop_os.repolib.modifysources' + ) + if self.system_repo: + self.system_repo.load_from_file() + self.system_repo.set_suite_enabled(suite=suite, enabled=enable) + self.system_repo.save_to_disk() + return True + return False + + @classmethod + def _log_in_file(klass, filename, string): + date = time.asctime(time.localtime()) + ff = open(filename, "a") + ff.write("%s : %s\n" %(date,str(string))) + ff.close() + + @classmethod + def _strip_source_line(self, source): + source = source.replace("#", "# ") + source = source.replace("[", "") + source = source.replace("]", "") + source = source.replace("'", "") + source = source.replace(" ", " ") + return source + + def _check_polkit_privilege(self, sender, conn, privilege): + # from jockey + '''Verify that sender has a given PolicyKit privilege. + sender is the sender's (private) D-BUS name, such as ":1:42" + (sender_keyword in @dbus.service.methods). conn is + the dbus.Connection object (connection_keyword in + @dbus.service.methods). privilege is the PolicyKit privilege string. + This method returns if the caller is privileged, and otherwise throws a + PermissionDeniedByPolicy exception. + ''' + if sender is None and conn is None: + # called locally, not through D-BUS + return + if not self.enforce_polkit: + # that happens for testing purposes when running on the session + # bus, and it does not make sense to restrict operations here + return + + # get peer PID + if self.dbus_info is None: + self.dbus_info = dbus.Interface(conn.get_object('org.freedesktop.DBus', + '/org/freedesktop/DBus/Bus', False), 'org.freedesktop.DBus') + pid = self.dbus_info.GetConnectionUnixProcessID(sender) + + # query PolicyKit + if self.polkit is None: + self.polkit = dbus.Interface(dbus.SystemBus().get_object( + 'org.freedesktop.PolicyKit1', + '/org/freedesktop/PolicyKit1/Authority', False), + 'org.freedesktop.PolicyKit1.Authority') + try: + # we don't need is_challenge return here, since we call with AllowUserInteraction + (is_auth, _, details) = self.polkit.CheckAuthorization( + ('unix-process', {'pid': dbus.UInt32(pid, variant_level=1), + 'start-time': dbus.UInt64(0, variant_level=1)}), + privilege, {'': ''}, dbus.UInt32(1), '', timeout=600) + except dbus.DBusException as e: + if e._dbus_error_name == 'org.freedesktop.DBus.Error.ServiceUnknown': + # polkitd timed out, connect again + self.polkit = None + return self._check_polkit_privilege(sender, conn, privilege) + else: + raise + + if not is_auth: + Repo._log_in_file('/tmp/repolib.log','_check_polkit_privilege: sender %s on connection %s pid %i is not authorized for %s: %s' % + (sender, conn, pid, privilege, str(details))) + raise PermissionDeniedByPolicy(privilege) + +if __name__ == '__main__': + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + + bus = dbus.SystemBus() + name = dbus.service.BusName("org.pop_os.repolib", bus) + object = Repo(bus, '/Repo') + + mainloop = GLib.MainLoop() + mainloop.run() \ No newline at end of file diff --git a/repolib/usr/share/bash-completion/completions/apt-manage b/repolib/usr/share/bash-completion/completions/apt-manage new file mode 100755 index 0000000..f881bc4 --- /dev/null +++ b/repolib/usr/share/bash-completion/completions/apt-manage @@ -0,0 +1,42 @@ +# Debian apt-manage completion + +_apt_manage() +{ + local cur prev words cword package + _init_completion -n ':=' || return + + local special i + i=0 + for (( i=0; i < ${#words[@]}-1; i++ )); do + if [[ ${words[i]} == @(add|list|modify|remove|key) ]]; then + special=${words[i]} + fi + done + + if [[ -n $special ]]; then + case $special in + list|modify|remove|key) + COMPREPLY=( $( compgen -W '$(apt-manage list -n)' -- "$cur" ) ) + return + ;; + *) + ;; + esac + fi + + + if [[ "$cur" == -* ]]; then + return + # COMPREPLY=( $(compgen -W ' + # --help --disable --source-code --expand + # --verbose --legacy --no-names + # --enable --disable --name --add-suite --remove-suite + # --add-component --remove-component --add-uri --remove-uri + # ' -- "$cur") ) + else + COMPREPLY=( $(compgen -W 'add list modify remove key' \ + -- "$cur") ) + fi + +} && +complete -F _apt_manage apt-manage diff --git a/repolib/usr/share/dbus-1/system-services/org.pop_os.repolib.service b/repolib/usr/share/dbus-1/system-services/org.pop_os.repolib.service new file mode 100644 index 0000000..2f354e3 --- /dev/null +++ b/repolib/usr/share/dbus-1/system-services/org.pop_os.repolib.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=org.pop_os.repolib +Exec=/usr/bin/python3 /usr/lib/repolib/service.py +User=root diff --git a/repolib/usr/share/polkit-1/actions/org.pop_os.repolib.policy b/repolib/usr/share/polkit-1/actions/org.pop_os.repolib.policy new file mode 100644 index 0000000..7a6caef --- /dev/null +++ b/repolib/usr/share/polkit-1/actions/org.pop_os.repolib.policy @@ -0,0 +1,20 @@ + + + + + Repoman + https://github.com/pop-os/repolib + x-system-software-sources + + + Modifies a Debian Repository in the system software sources. + Authentication is required to change software sources. + + auth_admin_keep + auth_admin_keep + auth_admin_keep + + + \ No newline at end of file diff --git a/repolib/usr/share/zsh/vendor-completions/_apt-manage b/repolib/usr/share/zsh/vendor-completions/_apt-manage new file mode 100644 index 0000000..ffe1fb1 --- /dev/null +++ b/repolib/usr/share/zsh/vendor-completions/_apt-manage @@ -0,0 +1,71 @@ +#compdef apt-manage +typeset -A opt_args + +typeset -A opt_args + +_arguments -C \ + '1:cmd:->cmds' \ + '2:sources:->source_lists' \ + '*:: :->args' \ +&& ret=0 + +case "$state" in + (cmds) + local commands; commands=( + 'add' + 'list' + 'modify' + 'remove' + 'key' + ) + _describe -t commands 'command' commands && ret=0 + ;; + (source_lists) + local sources + sources=( $(apt-manage list -n)) + _describe -t sources 'source' sources && ret=0 + ;; + (args) + local arguments + arguments=( + # Main command + '--help' + # Add subcommand + '--disable' + '--source-code' + '--terse' + '--name' + '--identifier' + '--format' + '--skip-keys' + # Key Ssbcommand + '--path' + '--url' + '--ascii' + '--fingerprint' + '--keyserver' + '--info' + '--remove' + # List subcommand + '--legacy' + '--verbose' + '--all' + '--file-names' + # Modify subcommand + '--enable' + '--source-enable' + '--source-disable' + '--add-uri' + '--add-suite' + '--add-component' + '--remove-uri' + '--remove-suite' + '--remove-component' + # Remove subcommand + '--assume-yes' + ) + _describe -t arguments 'argument' arguments && ret=0 + ;; +esac + +return 1 \ No newline at end of file