This commit is contained in:
Ward Nakchbandi (Cosmic Fusion) 2023-05-24 18:11:09 +03:00
parent 6865ce91fa
commit 27c9f79dd5
40 changed files with 6516 additions and 11 deletions

19
debian/rules vendored
View File

@ -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 $@

11
main.sh
View File

@ -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 ../

2
python3-repolib.install Normal file
View File

@ -0,0 +1,2 @@
usr
etc

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<type>system</type>
<!-- Only root can own the service -->
<policy user="root">
<allow own="org.pop_os.repolib"/>
<allow send_destination="org.pop_os.repolib"/>
<allow receive_sender="org.pop_os.repolib"/>
</policy>
<policy group="adm">
<allow send_destination="org.pop_os.repolib"/>
<allow receive_sender="org.pop_os.repolib"/>
</policy>
<policy group="sudo">
<allow send_destination="org.pop_os.repolib"/>
<allow receive_sender="org.pop_os.repolib"/>
</policy>
</busconfig>

85
repolib/usr/bin/apt-manage Executable file
View File

@ -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 <https://www.gnu.org/licenses/>.
"""
#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)

View File

@ -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 <https://repolib.rtfd.io/>`_.
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

View File

@ -0,0 +1,4 @@
repolib
repolib/command
repolib/shortcuts
repolib/unittest

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
__version__ = "2.2.0"

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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()

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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.'
)

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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]'
)

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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).'
)

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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,
}

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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) <info@system76.com>']
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())

View File

@ -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 <https://www.gnu.org/licenses/>.
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'])

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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))

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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])

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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)

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
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

View File

@ -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 <https://www.gnu.org/licenses/>.
"""
#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='<sourceline>'
)
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))

View File

@ -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 <http://www.gnu.org/licenses/>.
'''
#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()

View File

@ -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

View File

@ -0,0 +1,4 @@
[D-BUS Service]
Name=org.pop_os.repolib
Exec=/usr/bin/python3 /usr/lib/repolib/service.py
User=root

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>
<vendor>Repoman</vendor>
<vendor_url>https://github.com/pop-os/repolib</vendor_url>
<icon_name>x-system-software-sources</icon_name>
<action id="org.pop_os.repolib.modifysources">
<description>Modifies a Debian Repository in the system software sources.</description>
<message>Authentication is required to change software sources.</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
</policyconfig>

View File

@ -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