mirror of
https://github.com/openSUSE/osc.git
synced 2024-12-25 17:36:13 +01:00
Switch http_request() to urllib3
Use connection pools for better performance. Replace M2Crypto with cryptography and urllib3's ssl context.
This commit is contained in:
parent
d8d4b0831c
commit
93bc0e4731
9
.github/workflows/unittests.yaml
vendored
9
.github/workflows/unittests.yaml
vendored
@ -30,8 +30,7 @@ jobs:
|
||||
- 'opensuse/tumbleweed'
|
||||
|
||||
# CentOS Stream
|
||||
# stream9 doesn't contain m2crypto required by osc
|
||||
# - 'quay.io/centos/centos:stream9'
|
||||
- 'quay.io/centos/centos:stream9'
|
||||
|
||||
# Debian
|
||||
- 'debian:stable'
|
||||
@ -50,7 +49,7 @@ jobs:
|
||||
zypper --non-interactive --gpg-auto-import-keys refresh
|
||||
zypper --non-interactive dist-upgrade
|
||||
zypper --non-interactive install git-lfs
|
||||
zypper --non-interactive install diffstat diffutils python3 python3-chardet python3-M2Crypto python3-pip python3-rpm python3-setuptools
|
||||
zypper --non-interactive install diffstat diffutils python3 python3-cryptography python3-pip python3-rpm python3-setuptools python3-urllib3
|
||||
|
||||
- name: 'Install packages (Fedora/CentOS)'
|
||||
if: ${{ startsWith(matrix.container, 'fedora:') || contains(matrix.container, 'centos:') }}
|
||||
@ -58,7 +57,7 @@ jobs:
|
||||
dnf -y makecache
|
||||
dnf -y distro-sync
|
||||
dnf -y install git-lfs
|
||||
dnf -y install diffstat diffutils python3 python3-chardet python3-m2crypto python3-pip python3-rpm python3-setuptools
|
||||
dnf -y install diffstat diffutils python3 python3-cryptography python3-pip python3-rpm python3-setuptools python3-urllib3
|
||||
|
||||
- name: 'Install packages (Debian/Ubuntu)'
|
||||
if: ${{ startsWith(matrix.container, 'debian:') || startsWith(matrix.container, 'ubuntu:') }}
|
||||
@ -66,7 +65,7 @@ jobs:
|
||||
apt-get -y update
|
||||
apt-get -y upgrade
|
||||
apt-get -y --no-install-recommends install git-lfs
|
||||
apt-get -y --no-install-recommends install diffstat diffutils python3 python3-chardet python3-m2crypto python3-pip python3-rpm python3-setuptools
|
||||
apt-get -y --no-install-recommends install diffstat diffutils python3 python3-cryptography python3-pip python3-rpm python3-setuptools python3-urllib3
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
|
@ -8,26 +8,18 @@ from __future__ import print_function
|
||||
import errno
|
||||
import os.path
|
||||
import pdb
|
||||
import ssl
|
||||
import sys
|
||||
import signal
|
||||
import traceback
|
||||
|
||||
from osc import oscerr
|
||||
from .oscsslexcp import NoSecureSSLError
|
||||
from .oscssl import CertVerificationError
|
||||
from osc.util.cpio import CpioError
|
||||
from osc.util.packagequery import PackageError
|
||||
from osc.util.helper import decode_it
|
||||
from osc.OscConfigParser import configparser
|
||||
|
||||
try:
|
||||
from M2Crypto.SSL.Checker import SSLVerificationError
|
||||
from M2Crypto.SSL import SSLError as SSLError
|
||||
except:
|
||||
class SSLError(Exception):
|
||||
pass
|
||||
class SSLVerificationError(Exception):
|
||||
pass
|
||||
|
||||
try:
|
||||
# import as RPMError because the class "error" is too generic
|
||||
from rpm import error as RPMError
|
||||
@ -36,13 +28,9 @@ except:
|
||||
class RPMError(Exception):
|
||||
pass
|
||||
|
||||
try:
|
||||
from http.client import HTTPException, BadStatusLine
|
||||
from urllib.error import URLError, HTTPError
|
||||
except ImportError:
|
||||
#python 2.x
|
||||
from httplib import HTTPException, BadStatusLine
|
||||
from urllib2 import URLError, HTTPError
|
||||
import urllib3.exceptions
|
||||
from http.client import HTTPException, BadStatusLine
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
# the good things are stolen from Matt Mackall's mercurial
|
||||
|
||||
@ -142,6 +130,10 @@ def run(prg, argv=None):
|
||||
msg += ' (%s)' % e._osc_host_port
|
||||
msg += ':\n'
|
||||
print(msg, e.reason, file=sys.stderr)
|
||||
except ssl.SSLError as e:
|
||||
if 'tlsv1' in str(e):
|
||||
print('The python on this system or the server does not support TLSv1.2', file=sys.stderr)
|
||||
print("SSL Error:", e, file=sys.stderr)
|
||||
except IOError as e:
|
||||
# ignore broken pipe
|
||||
if e.errno != errno.EPIPE:
|
||||
@ -182,14 +174,10 @@ def run(prg, argv=None):
|
||||
print('%s:' % e.fname, e.msg, file=sys.stderr)
|
||||
except RPMError as e:
|
||||
print(e, file=sys.stderr)
|
||||
except SSLError as e:
|
||||
if 'tlsv1' in str(e):
|
||||
print('The python on this system does not support TLSv1.2', file=sys.stderr)
|
||||
print("SSL Error:", e, file=sys.stderr)
|
||||
except SSLVerificationError as e:
|
||||
print("Certificate Verification Error:", e, file=sys.stderr)
|
||||
except NoSecureSSLError as e:
|
||||
except CertVerificationError as e:
|
||||
print(e, file=sys.stderr)
|
||||
except urllib3.exceptions.MaxRetryError as e:
|
||||
print(e.reason, file=sys.stderr)
|
||||
except CpioError as e:
|
||||
print(e, file=sys.stderr)
|
||||
except oscerr.OscBaseError as e:
|
||||
|
@ -35,7 +35,8 @@ except ImportError:
|
||||
# for 3.9+
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .conf import config, cookiejar
|
||||
from . import connection
|
||||
from .conf import config
|
||||
|
||||
from .meter import create_text_meter
|
||||
|
||||
@ -1092,7 +1093,7 @@ def main(apiurl, opts, argv):
|
||||
http_debug = config['http_debug'],
|
||||
modules = bi.modules,
|
||||
enable_cpio=not opts.disable_cpio_bulk_download and bi.enable_cpio,
|
||||
cookiejar=cookiejar,
|
||||
cookiejar=CookieJarAuthHandler(os.path.expanduser(conf.config["cookiejar"]))._cookiejar,
|
||||
download_api_only=opts.download_api_only)
|
||||
|
||||
if not opts.trust_all_projects:
|
||||
|
364
osc/conf.py
364
osc/conf.py
@ -37,39 +37,19 @@ The configuration dictionary could look like this:
|
||||
"""
|
||||
|
||||
import bz2
|
||||
import base64
|
||||
import errno
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import ssl
|
||||
import getpass
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
from http.cookiejar import LWPCookieJar, CookieJar
|
||||
from http.client import HTTPConnection, HTTPResponse
|
||||
from io import StringIO
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.error import URLError
|
||||
from urllib.request import HTTPBasicAuthHandler, HTTPCookieProcessor, HTTPPasswordMgrWithDefaultRealm, ProxyHandler
|
||||
from urllib.request import AbstractHTTPHandler, build_opener, proxy_bypass, HTTPSHandler
|
||||
from urllib.request import BaseHandler, parse_keqv_list, parse_http_list
|
||||
except ImportError:
|
||||
#python 2.x
|
||||
from cookielib import LWPCookieJar, CookieJar
|
||||
from httplib import HTTPConnection, HTTPResponse
|
||||
from StringIO import StringIO
|
||||
from urlparse import urlsplit
|
||||
from urllib2 import URLError, HTTPBasicAuthHandler, HTTPCookieProcessor, HTTPPasswordMgrWithDefaultRealm, ProxyHandler
|
||||
from urllib2 import AbstractHTTPHandler, build_opener, proxy_bypass, HTTPSHandler
|
||||
from urllib2 import BaseHandler, parse_keqv_list, parse_http_list
|
||||
from io import StringIO
|
||||
from urllib.error import URLError
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from . import OscConfigParser
|
||||
from osc import oscerr
|
||||
from osc.util.helper import raw_input, decode_it
|
||||
from .oscsslexcp import NoSecureSSLError
|
||||
from osc.util.helper import raw_input
|
||||
from osc import credentials
|
||||
|
||||
GENERIC_KEYRING = False
|
||||
@ -428,8 +408,6 @@ the apiurl \'%s\' does not exist in the config file. Please enter
|
||||
your credentials for this apiurl.
|
||||
"""
|
||||
|
||||
cookiejar = None
|
||||
|
||||
|
||||
def parse_apisrv_url(scheme, apisrv):
|
||||
if apisrv.startswith('http://') or apisrv.startswith('https://'):
|
||||
@ -502,334 +480,6 @@ def get_apiurl_usr(apiurl):
|
||||
return config['user']
|
||||
|
||||
|
||||
# workaround m2crypto issue:
|
||||
# if multiple SSL.Context objects are created
|
||||
# m2crypto only uses the last object which was created.
|
||||
# So we need to build a new opener everytime we switch the
|
||||
# apiurl (because different apiurls may have different
|
||||
# cafile/capath locations)
|
||||
def _build_opener(apiurl):
|
||||
from osc.core import __version__
|
||||
global config
|
||||
|
||||
class OscHTTPAuthHandler(HTTPBasicAuthHandler, object):
|
||||
# python2: inherit from object in order to make it a new-style class
|
||||
# (HTTPBasicAuthHandler is not a new-style class)
|
||||
|
||||
def __init__(self, password_mgr=None, signatureauthhandler=None):
|
||||
super(self.__class__, self).__init__(password_mgr)
|
||||
self.signatureauthhandler = signatureauthhandler
|
||||
|
||||
def add_parent(self, parent):
|
||||
super(self.__class__, self).add_parent(parent)
|
||||
if self.signatureauthhandler:
|
||||
self.signatureauthhandler.add_parent(parent)
|
||||
|
||||
def _rewind_request(self, req):
|
||||
if hasattr(req.data, 'seek'):
|
||||
# if the request is issued again (this time with an
|
||||
# Authorization header), the file's offset has to be
|
||||
# repositioned to the beginning of the file (otherwise,
|
||||
# a 0-length body is sent which most likely does not match
|
||||
# the Content-Length header (if present))
|
||||
req.data.seek(0)
|
||||
|
||||
def http_error_401(self, req, fp, code, msg, headers):
|
||||
self._rewind_request(req)
|
||||
authreqs = {}
|
||||
for authreq in headers.get_all('www-authenticate', []):
|
||||
scheme = authreq.split()[0].lower()
|
||||
authreqs[scheme] = authreq
|
||||
|
||||
if 'signature' in authreqs \
|
||||
and self.signatureauthhandler \
|
||||
and (
|
||||
# sshkey explicitly set in the config file, use it instead of doing basic auth
|
||||
self.signatureauthhandler.sshkey_known()
|
||||
or (
|
||||
# can't fall-back to basic auth, because server doesn't support it
|
||||
'basic' not in authreqs
|
||||
# can't fall-back to basic auth, because there's no password provided
|
||||
or not self.passwd.find_user_password(None, apiurl)[1]
|
||||
)):
|
||||
del headers['www-authenticate']
|
||||
headers['www-authenticate'] = authreqs['signature']
|
||||
return self.signatureauthhandler.http_error_401(req, fp, code, msg, headers)
|
||||
|
||||
if 'basic' in authreqs:
|
||||
del headers['www-authenticate']
|
||||
headers['www-authenticate'] = authreqs['basic']
|
||||
|
||||
response = super(self.__class__, self).http_error_401(req, fp, code, msg, headers)
|
||||
# workaround for http://bugs.python.org/issue9639
|
||||
if hasattr(self, 'retried'):
|
||||
self.retried = 0
|
||||
return response
|
||||
|
||||
class OscHTTPSignatureAuthHandler(BaseHandler, object):
|
||||
def __init__(self, user, sshkey):
|
||||
super(self.__class__, self).__init__()
|
||||
self.user = user
|
||||
self.sshkey = sshkey
|
||||
|
||||
def list_ssh_agent_keys(self):
|
||||
cmd = ['ssh-add', '-l']
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
stdout, _ = proc.communicate()
|
||||
if proc.returncode == 0 and stdout.strip():
|
||||
return [self.get_fingerprint(line) for line in stdout.splitlines()]
|
||||
else:
|
||||
return []
|
||||
|
||||
def is_ssh_private_keyfile(self, keyfile_path):
|
||||
if not os.path.isfile(keyfile_path):
|
||||
return False
|
||||
with open(keyfile_path, "r") as f:
|
||||
try:
|
||||
line = f.readline(100).strip()
|
||||
except UnicodeDecodeError:
|
||||
# skip binary files
|
||||
return False
|
||||
if line == "-----BEGIN RSA PRIVATE KEY-----":
|
||||
return True
|
||||
if line == "-----BEGIN OPENSSH PRIVATE KEY-----":
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_ssh_public_keyfile(self, keyfile_path):
|
||||
if not os.path.isfile(keyfile_path):
|
||||
return False
|
||||
return keyfile_path.endswith(".pub")
|
||||
|
||||
@staticmethod
|
||||
def get_fingerprint(line):
|
||||
parts = line.strip().split(b" ")
|
||||
if len(parts) < 2:
|
||||
raise ValueError("Unable to retrieve ssh key fingerprint from line: {}".format(line))
|
||||
return parts[1]
|
||||
|
||||
def list_ssh_dir_keys(self):
|
||||
sshdir = os.path.expanduser('~/.ssh')
|
||||
keys_in_home_ssh = {}
|
||||
for keyfile in os.listdir(sshdir):
|
||||
if keyfile.startswith(("agent-", "authorized_keys", "config", "known_hosts")):
|
||||
# skip files that definitely don't contain keys
|
||||
continue
|
||||
|
||||
keyfile_path = os.path.join(sshdir, keyfile)
|
||||
# public key alone may be sufficient because the private key
|
||||
# can get loaded into ssh-agent from gpg (yubikey works this way)
|
||||
is_public = self.is_ssh_public_keyfile(keyfile_path)
|
||||
# skip private detection if we think the key is a public one already
|
||||
is_private = False if is_public else self.is_ssh_private_keyfile(keyfile_path)
|
||||
|
||||
if not is_public and not is_private:
|
||||
continue
|
||||
|
||||
cmd = ["ssh-keygen", "-lf", keyfile_path]
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
stdout, _ = proc.communicate()
|
||||
if proc.returncode == 0:
|
||||
fingerprint = self.get_fingerprint(stdout)
|
||||
if fingerprint and (fingerprint not in keys_in_home_ssh or is_private):
|
||||
# prefer path to a private key
|
||||
keys_in_home_ssh[fingerprint] = keyfile_path
|
||||
return keys_in_home_ssh
|
||||
|
||||
def guess_keyfile(self):
|
||||
keys_in_agent = self.list_ssh_agent_keys()
|
||||
if keys_in_agent:
|
||||
keys_in_home_ssh = self.list_ssh_dir_keys()
|
||||
for fingerprint in keys_in_agent:
|
||||
if fingerprint in keys_in_home_ssh:
|
||||
return keys_in_home_ssh[fingerprint]
|
||||
sshdir = os.path.expanduser('~/.ssh')
|
||||
keyfiles = ('id_ed25519', 'id_ed25519_sk', 'id_rsa', 'id_ecdsa', 'id_ecdsa_sk', 'id_dsa')
|
||||
for keyfile in keyfiles:
|
||||
keyfile_path = os.path.join(sshdir, keyfile)
|
||||
if os.path.isfile(keyfile_path):
|
||||
return keyfile_path
|
||||
raise oscerr.OscIOError(None, 'could not guess ssh identity keyfile')
|
||||
|
||||
def ssh_sign(self, data, namespace, keyfile=None):
|
||||
try:
|
||||
data = bytes(data, 'utf-8')
|
||||
except:
|
||||
pass
|
||||
if not keyfile:
|
||||
keyfile = self.guess_keyfile()
|
||||
else:
|
||||
if '/' not in keyfile:
|
||||
keyfile = '~/.ssh/' + keyfile
|
||||
keyfile = os.path.expanduser(keyfile)
|
||||
|
||||
cmd = ['ssh-keygen', '-Y', 'sign', '-f', keyfile, '-n', namespace, '-q']
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||
stdout, _ = proc.communicate(data)
|
||||
if proc.returncode:
|
||||
raise oscerr.OscIOError(None, 'ssh-keygen signature creation failed: %d' % proc.returncode)
|
||||
|
||||
signature = decode_it(stdout)
|
||||
match = re.match(r"\A-----BEGIN SSH SIGNATURE-----\n(.*)\n-----END SSH SIGNATURE-----", signature, re.S)
|
||||
if not match:
|
||||
raise oscerr.OscIOError(None, 'could not extract ssh signature')
|
||||
return base64.b64decode(match.group(1))
|
||||
|
||||
def get_authorization(self, req, chal):
|
||||
realm = chal.get('realm', '')
|
||||
now = int(time.time())
|
||||
sigdata = "(created): %d" % now
|
||||
signature = self.ssh_sign(sigdata, realm, self.sshkey)
|
||||
signature = decode_it(base64.b64encode(signature))
|
||||
return 'keyId="%s",algorithm="ssh",headers="(created)",created=%d,signature="%s"' \
|
||||
% (self.user, now, signature)
|
||||
|
||||
def retry_http_signature_auth(self, req, auth):
|
||||
old_auth_val = req.get_header('Authorization', None)
|
||||
if old_auth_val:
|
||||
old_scheme = old_auth_val.split()[0]
|
||||
if old_scheme.lower() == 'signature':
|
||||
return None
|
||||
token, challenge = auth.split(' ', 1)
|
||||
chal = parse_keqv_list(filter(None, parse_http_list(challenge)))
|
||||
auth = self.get_authorization(req, chal)
|
||||
if auth:
|
||||
auth_val = 'Signature %s' % auth
|
||||
req.add_unredirected_header('Authorization', auth_val)
|
||||
return self.parent.open(req, timeout=req.timeout)
|
||||
|
||||
def http_error_401(self, req, fp, code, msg, headers):
|
||||
authreq = headers.get('www-authenticate', None)
|
||||
if authreq:
|
||||
scheme = authreq.split()[0]
|
||||
if scheme.lower() == 'signature':
|
||||
return self.retry_http_signature_auth(req, authreq)
|
||||
raise ValueError("OscHTTPSignatureAuthHandler does not support"
|
||||
" the following scheme: '%s'" % scheme)
|
||||
|
||||
def sshkey_known(self):
|
||||
return self.sshkey is not None
|
||||
|
||||
|
||||
if 'last_opener' not in _build_opener.__dict__:
|
||||
_build_opener.last_opener = (None, None)
|
||||
if apiurl == _build_opener.last_opener[0]:
|
||||
return _build_opener.last_opener[1]
|
||||
|
||||
# respect no_proxy env variable
|
||||
if proxy_bypass(apiurl):
|
||||
# initialize with empty dict
|
||||
proxyhandler = ProxyHandler({})
|
||||
else:
|
||||
# read proxies from env
|
||||
proxyhandler = ProxyHandler()
|
||||
|
||||
options = config['api_host_options'][apiurl]
|
||||
signatureauthhandler = OscHTTPSignatureAuthHandler(options['user'], options['sshkey'])
|
||||
# with None as first argument, it will always use this username/password
|
||||
# combination for urls for which arg2 (apisrv) is a super-url
|
||||
authhandler = OscHTTPAuthHandler(HTTPPasswordMgrWithDefaultRealm(), signatureauthhandler)
|
||||
authhandler.add_password(None, apiurl, options['user'], options['pass'])
|
||||
|
||||
if options['sslcertck']:
|
||||
try:
|
||||
from . import oscssl
|
||||
from M2Crypto import m2urllib2
|
||||
except ImportError as e:
|
||||
print(e)
|
||||
raise NoSecureSSLError('M2Crypto is needed to access %s in a secure way.\nPlease install python-m2crypto.' % apiurl)
|
||||
|
||||
cafile = options.get('cafile', None)
|
||||
capath = options.get('capath', None)
|
||||
if not cafile and not capath:
|
||||
for i in ['/etc/pki/tls/cert.pem', '/etc/ssl/certs']:
|
||||
if os.path.isfile(i):
|
||||
cafile = i
|
||||
break
|
||||
elif os.path.isdir(i):
|
||||
capath = i
|
||||
break
|
||||
if not cafile and not capath:
|
||||
raise oscerr.OscIOError(None, 'No CA certificates found. (You may want to install ca-certificates-mozilla package)')
|
||||
ctx = oscssl.mySSLContext()
|
||||
if ctx.load_verify_locations(capath=capath, cafile=cafile) != 1:
|
||||
raise oscerr.OscIOError(None, 'No CA certificates found. (You may want to install ca-certificates-mozilla package)')
|
||||
opener = m2urllib2.build_opener(ctx, oscssl.myHTTPSHandler(ssl_context=ctx, appname='osc'), HTTPCookieProcessor(cookiejar), authhandler, proxyhandler)
|
||||
else:
|
||||
handlers = [HTTPCookieProcessor(cookiejar), authhandler, proxyhandler]
|
||||
try:
|
||||
# disable ssl cert check in python >= 2.7.9
|
||||
ctx = ssl._create_unverified_context()
|
||||
handlers.append(HTTPSHandler(context=ctx))
|
||||
except AttributeError:
|
||||
pass
|
||||
print("WARNING: SSL certificate checks disabled. Connection is insecure!\n", file=sys.stderr)
|
||||
opener = build_opener(*handlers)
|
||||
opener.addheaders = [('User-agent', 'osc/%s' % __version__)]
|
||||
_build_opener.last_opener = (apiurl, opener)
|
||||
return opener
|
||||
|
||||
|
||||
def init_basicauth(config, config_mtime):
|
||||
"""initialize urllib2 with the credentials for Basic Authentication"""
|
||||
|
||||
def filterhdrs(meth, ishdr, *hdrs):
|
||||
# this is so ugly but httplib doesn't use
|
||||
# a logger object or such
|
||||
def new_method(self, *args, **kwargs):
|
||||
# check if this is a recursive call (note: we do not
|
||||
# have to care about thread safety)
|
||||
is_rec_call = getattr(self, '_orig_stdout', None) is not None
|
||||
try:
|
||||
if not is_rec_call:
|
||||
self._orig_stdout = sys.stdout
|
||||
sys.stdout = StringIO()
|
||||
meth(self, *args, **kwargs)
|
||||
hdr = sys.stdout.getvalue()
|
||||
finally:
|
||||
# restore original stdout
|
||||
if not is_rec_call:
|
||||
sys.stdout = self._orig_stdout
|
||||
del self._orig_stdout
|
||||
for i in hdrs:
|
||||
if ishdr:
|
||||
hdr = re.sub(r'%s:[^\\r]*\\r\\n' % i, '', hdr)
|
||||
else:
|
||||
hdr = re.sub(i, '', hdr)
|
||||
sys.stdout.write(hdr)
|
||||
new_method.__name__ = meth.__name__
|
||||
return new_method
|
||||
|
||||
if config['http_debug'] and not config['http_full_debug']:
|
||||
HTTPConnection.send = filterhdrs(HTTPConnection.send, True, 'Cookie', 'Authorization')
|
||||
HTTPResponse.begin = filterhdrs(HTTPResponse.begin, False, 'header: Set-Cookie.*\n')
|
||||
|
||||
if config['http_debug']:
|
||||
# brute force
|
||||
def urllib2_debug_init(self, debuglevel=0):
|
||||
self._debuglevel = 1
|
||||
AbstractHTTPHandler.__init__ = urllib2_debug_init
|
||||
|
||||
cookie_file = os.path.expanduser(config['cookiejar'])
|
||||
if not os.path.exists(os.path.dirname(cookie_file)):
|
||||
os.makedirs(os.path.dirname(cookie_file), mode=0o700)
|
||||
global cookiejar
|
||||
cookiejar = LWPCookieJar(cookie_file)
|
||||
try:
|
||||
cookiejar.load(ignore_discard=True)
|
||||
if int(round(config_mtime)) > int(os.stat(cookie_file).st_mtime):
|
||||
cookiejar.clear()
|
||||
cookiejar.save()
|
||||
except IOError:
|
||||
try:
|
||||
fd = os.open(cookie_file, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
|
||||
os.close(fd)
|
||||
except IOError:
|
||||
# hmm is any good reason why we should catch the IOError?
|
||||
#print 'Unable to create cookiejar file: \'%s\'. Using RAM-based cookies.' % cookie_file
|
||||
cookiejar = CookieJar()
|
||||
|
||||
|
||||
def get_configParser(conffile=None, force_read=False):
|
||||
"""
|
||||
Returns an ConfigParser() object. After its first invocation the
|
||||
@ -1246,8 +896,10 @@ def get_config(override_conffile=None,
|
||||
e.file = conffile
|
||||
raise e
|
||||
|
||||
# finally, initialize urllib2 for to use the credentials for Basic Authentication
|
||||
init_basicauth(config, os.stat(conffile).st_mtime)
|
||||
# enable connection debugging after all config options are set
|
||||
from .connection import enable_http_debug
|
||||
enable_http_debug(config)
|
||||
|
||||
|
||||
def identify_conf():
|
||||
# needed for compat reasons(users may have their oscrc still in ~
|
||||
|
613
osc/connection.py
Normal file
613
osc/connection.py
Normal file
@ -0,0 +1,613 @@
|
||||
import base64
|
||||
import errno
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
|
||||
import http.client
|
||||
import http.cookiejar
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib3.exceptions
|
||||
import urllib3.poolmanager
|
||||
import urllib3.response
|
||||
import urllib3.util
|
||||
|
||||
from . import conf
|
||||
from . import oscerr
|
||||
from . import oscssl
|
||||
from .util.helper import decode_it
|
||||
|
||||
|
||||
class MockRequest:
|
||||
"""
|
||||
Mock a request object for `cookiejar.extract_cookies()`
|
||||
and `cookiejar.add_cookie_header()`.
|
||||
"""
|
||||
|
||||
def __init__(self, url, headers):
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
self.unverifiable = False
|
||||
self.type = "https"
|
||||
|
||||
def get_full_url(self):
|
||||
return self.url
|
||||
|
||||
def get_header(self, header_name, default=None):
|
||||
return self.headers.get(header_name, default)
|
||||
|
||||
def has_header(self, header_name):
|
||||
return (header_name in self.headers)
|
||||
|
||||
def add_unredirected_header(self, key, val):
|
||||
# modifies the `headers` variable that was passed to object's constructor
|
||||
self.headers[key] = val
|
||||
|
||||
|
||||
def enable_http_debug(config):
|
||||
if not int(config["http_debug"]) and not int(config["http_full_debug"]):
|
||||
return
|
||||
|
||||
# HACK: override HTTPResponse's init to increase debug level
|
||||
old_HTTPResponse__init__ = http.client.HTTPResponse.__init__
|
||||
|
||||
def new_HTTPResponse__init__(self, *args, **kwargs):
|
||||
old_HTTPResponse__init__(self, *args, **kwargs)
|
||||
self.debuglevel = 1
|
||||
http.client.HTTPResponse.__init__ = new_HTTPResponse__init__
|
||||
|
||||
# increase HTTPConnection debug level
|
||||
http.client.HTTPConnection.debuglevel = 1
|
||||
|
||||
# HACK: because HTTPResponse's debug data uses print(),
|
||||
# let's inject custom print() function to that module
|
||||
def new_print(*args, file=None):
|
||||
if not int(config["http_full_debug"]) and args:
|
||||
# hide private data (authorization and cookies) when full debug is not enabled
|
||||
if args[:2] == ("header:", "Set-Cookie:"):
|
||||
return
|
||||
if args[0] == "send:":
|
||||
args = list(args)
|
||||
# (?<=...) - '...' must be present before the pattern (positive lookbehind assertion)
|
||||
args[1] = re.sub(r"(?<=\\r\\n)authorization:.*?\\r\\n", "", args[1], re.I)
|
||||
args[1] = re.sub(r"(?<=\\r\\n)Cookie:.*?\\r\\n", "", args[1], re.I)
|
||||
print("DEBUG:", *args, file=sys.stderr)
|
||||
http.client.print = new_print
|
||||
|
||||
|
||||
def get_proxy_manager(env):
|
||||
proxy_url = os.environ.get(env, None)
|
||||
|
||||
if not proxy_url:
|
||||
return
|
||||
|
||||
proxy_purl = urllib3.util.parse_url(proxy_url)
|
||||
|
||||
# rebuild proxy url in order to remove auth because ProxyManager would fail on it
|
||||
if proxy_purl.port:
|
||||
proxy_url = f"{proxy_purl.scheme}://{proxy_purl.host}:{proxy_purl.port}"
|
||||
else:
|
||||
proxy_url = f"{proxy_purl.scheme}://{proxy_purl.host}"
|
||||
|
||||
# import osc.core here to avoid cyclic imports
|
||||
from . import core
|
||||
|
||||
proxy_headers = urllib3.make_headers(
|
||||
proxy_basic_auth=proxy_purl.auth,
|
||||
user_agent=f"osc/{core.__version__}",
|
||||
)
|
||||
|
||||
manager = urllib3.ProxyManager(proxy_url, proxy_headers=proxy_headers)
|
||||
return manager
|
||||
|
||||
|
||||
# Instantiate on first use in `http_request()`.
|
||||
# Each `apiurl` requires a differently configured pool
|
||||
# (incl. trusted keys for example).
|
||||
CONNECTION_POOLS = {}
|
||||
|
||||
|
||||
# Pool manager for requests outside apiurls.
|
||||
POOL_MANAGER = urllib3.PoolManager()
|
||||
|
||||
|
||||
# Proxy manager for HTTP connections.
|
||||
HTTP_PROXY_MANAGER = get_proxy_manager("HTTP_PROXY")
|
||||
|
||||
|
||||
# Proxy manager for HTTPS connections.
|
||||
HTTPS_PROXY_MANAGER = get_proxy_manager("HTTPS_PROXY")
|
||||
|
||||
|
||||
def http_request(method, url, headers=None, data=None, file=None):
|
||||
"""
|
||||
Send a HTTP request to a server.
|
||||
|
||||
Features:
|
||||
* Authentication ([apiurl]/{user,pass} in oscrc)
|
||||
* Session cookie support (~/.local/state/osc/cookiejar)
|
||||
* SSL certificate verification incl. managing trusted certs
|
||||
* SSL certificate verification bypass (if [apiurl]/sslcertck=0 in oscrc)
|
||||
* Expired SSL certificates are no longer accepted. Either prolong them or set sslcertck=0.
|
||||
* Proxy support (HTTPS_PROXY env, NO_PROXY is respected)
|
||||
* Retries (http_retries in oscrc)
|
||||
* Requests outside apiurl (incl. proxy support)
|
||||
* Connection debugging (-H/--http-debug, --http-full-debug)
|
||||
|
||||
:param method: HTTP request method (such as GET, POST, PUT, DELETE).
|
||||
:param url: The URL to perform the request on.
|
||||
:param headers: Dictionary of custom headers to send.
|
||||
:param data: Data to send in the request body (conflicts with `file`).
|
||||
:param file: Path to a file to send as data in the request body (conflicts with `data`).
|
||||
"""
|
||||
|
||||
# import osc.core here to avoid cyclic imports
|
||||
from . import core
|
||||
|
||||
purl = urllib3.util.parse_url(url)
|
||||
apiurl = conf.extract_known_apiurl(url)
|
||||
headers = urllib3.response.HTTPHeaderDict(headers or {})
|
||||
|
||||
# identify osc
|
||||
headers.update(urllib3.make_headers(user_agent=f"osc/{core.__version__}"))
|
||||
|
||||
if data and file:
|
||||
raise RuntimeError('Specify either `data` or `file`')
|
||||
elif data:
|
||||
if hasattr(data, "encode"):
|
||||
data = data.encode("utf-8")
|
||||
content_length = len(data)
|
||||
elif file:
|
||||
content_length = os.path.getsize(file)
|
||||
data = open(file, "rb")
|
||||
else:
|
||||
content_length = 0
|
||||
|
||||
if content_length:
|
||||
headers.add("Content-Length", str(content_length))
|
||||
|
||||
# handle requests that go outside apiurl
|
||||
# do not set auth cookie or auth credentials
|
||||
if not apiurl:
|
||||
if purl.scheme == "http" and HTTP_PROXY_MANAGER and not urllib.request.proxy_bypass(url):
|
||||
# connection through proxy
|
||||
manager = HTTP_PROXY_MANAGER
|
||||
elif purl.scheme == "https" and HTTPS_PROXY_MANAGER and not urllib.request.proxy_bypass(url):
|
||||
# connection through proxy
|
||||
manager = HTTPS_PROXY_MANAGER
|
||||
else:
|
||||
# direct connection
|
||||
manager = POOL_MANAGER
|
||||
|
||||
response = manager.urlopen(method, url, body=data, headers=headers, preload_content=False)
|
||||
|
||||
if response.status / 100 != 2:
|
||||
raise urllib.error.HTTPError(url, response.status, response.reason, response.headers, response)
|
||||
|
||||
return response
|
||||
|
||||
options = conf.config["api_host_options"][apiurl]
|
||||
|
||||
global CONNECTION_POOLS
|
||||
pool = CONNECTION_POOLS.get(apiurl, None)
|
||||
if not pool:
|
||||
pool_kwargs = {}
|
||||
pool_kwargs["retries"] = int(conf.config["http_retries"])
|
||||
|
||||
if purl.scheme == "https":
|
||||
ssl_context = oscssl.create_ssl_context()
|
||||
ssl_context.load_default_certs()
|
||||
# turn cert verification off if sslcertck = 0
|
||||
pool_kwargs["cert_reqs"] = "CERT_REQUIRED" if options["sslcertck"] else "CERT_NONE"
|
||||
pool_kwargs["ssl_context"] = ssl_context
|
||||
|
||||
if purl.scheme == "http" and HTTP_PROXY_MANAGER and not urllib.request.proxy_bypass(url):
|
||||
# connection through HTTP proxy
|
||||
pool = HTTP_PROXY_MANAGER.connection_from_host(
|
||||
host=purl.host,
|
||||
port=purl.port,
|
||||
scheme=purl.scheme,
|
||||
pool_kwargs=pool_kwargs
|
||||
)
|
||||
HTTP_PROXY_MANAGER.request('GET', url)
|
||||
elif purl.scheme == "https" and HTTPS_PROXY_MANAGER and not urllib.request.proxy_bypass(url):
|
||||
# connection through HTTPS proxy
|
||||
pool = HTTPS_PROXY_MANAGER.connection_from_host(
|
||||
host=purl.host,
|
||||
port=purl.port,
|
||||
scheme=purl.scheme,
|
||||
pool_kwargs=pool_kwargs
|
||||
)
|
||||
elif purl.scheme == "https":
|
||||
# direct connection
|
||||
pool = urllib3.HTTPSConnectionPool(host=purl.host, port=purl.port, **pool_kwargs)
|
||||
else:
|
||||
pool = urllib3.HTTPConnectionPool(host=purl.host, port=purl.port, **pool_kwargs)
|
||||
|
||||
if purl.scheme == "https":
|
||||
# inject ssl context instance into pool so we can use it later
|
||||
pool.ssl_context = ssl_context
|
||||
|
||||
# inject trusted cert store instance into pool so we can use it later
|
||||
pool.trusted_cert_store = oscssl.TrustedCertStore(ssl_context, purl.host, purl.port)
|
||||
|
||||
CONNECTION_POOLS[apiurl] = pool
|
||||
|
||||
auth_handlers = [
|
||||
CookieJarAuthHandler(os.path.expanduser(conf.config["cookiejar"])),
|
||||
SignatureAuthHandler(options["user"], options["sshkey"], options["pass"]),
|
||||
BasicAuthHandler(options["user"], options["pass"]),
|
||||
]
|
||||
|
||||
for handler in auth_handlers:
|
||||
# authenticate using a cookie (if available)
|
||||
success = handler.set_request_headers(url, headers)
|
||||
if success:
|
||||
break
|
||||
|
||||
if data or file:
|
||||
# osc/obs data is usually XML
|
||||
headers.add("Content-Type", "application/xml; charset=utf-8")
|
||||
|
||||
if purl.scheme == "http" and HTTP_PROXY_MANAGER:
|
||||
# HTTP proxy requires full URL with 'same host' checking off
|
||||
urlopen_url = url
|
||||
assert_same_host = False
|
||||
else:
|
||||
# everything else is fine with path only
|
||||
# join path and query, ignore the remaining args; args are (scheme, netloc, path, query, fragment)
|
||||
urlopen_url = urllib.parse.urlunsplit(("", "", purl.path, purl.query, ""))
|
||||
assert_same_host = True
|
||||
|
||||
if int(conf.config['http_debug']):
|
||||
# use the hacked print() for consistency
|
||||
http.client.print(40 * '-')
|
||||
http.client.print(method, url)
|
||||
|
||||
try:
|
||||
response = pool.urlopen(
|
||||
method, urlopen_url, body=data, headers=headers,
|
||||
preload_content=False, assert_same_host=assert_same_host
|
||||
)
|
||||
except urllib3.exceptions.MaxRetryError as e:
|
||||
if not isinstance(e.reason, urllib3.exceptions.SSLError):
|
||||
# re-raise exceptions that are not related to SSL
|
||||
raise
|
||||
|
||||
if isinstance(e.reason.args[0], ssl.SSLCertVerificationError):
|
||||
self_signed_verify_codes = (
|
||||
oscssl.X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT,
|
||||
oscssl.X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN,
|
||||
)
|
||||
if e.reason.args[0].verify_code not in self_signed_verify_codes:
|
||||
# re-raise ssl exceptions that are not related to self-signed certs
|
||||
raise e.reason.args[0] from None
|
||||
else:
|
||||
# re-raise other than ssl exceptions
|
||||
raise e.reason.args[0] from None
|
||||
|
||||
# get the untrusted certificated from server
|
||||
cert = pool.trusted_cert_store.get_server_certificate()
|
||||
|
||||
# prompt user if we should trust the certificate
|
||||
pool.trusted_cert_store.prompt_trust(cert, reason=e.reason)
|
||||
|
||||
response = pool.urlopen(
|
||||
method, urlopen_url, body=data, headers=headers,
|
||||
preload_content=False, assert_same_host=assert_same_host
|
||||
)
|
||||
|
||||
if response.status == 401:
|
||||
# session cookie has expired, re-authenticate
|
||||
for handler in auth_handlers:
|
||||
success = handler.set_request_headers_after_401(url, headers, response)
|
||||
if success:
|
||||
break
|
||||
response = pool.urlopen(method, urlopen_url, body=data, headers=headers, preload_content=False)
|
||||
|
||||
if response.status / 100 != 2:
|
||||
raise urllib.error.HTTPError(url, response.status, response.reason, response.headers, response)
|
||||
|
||||
for handler in auth_handlers:
|
||||
handler.process_response(url, headers, response)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# pylint: disable=C0103,C0116
|
||||
def http_GET(*args, **kwargs):
|
||||
return http_request("GET", *args, **kwargs)
|
||||
|
||||
|
||||
# pylint: disable=C0103,C0116
|
||||
def http_POST(*args, **kwargs):
|
||||
return http_request("POST", *args, **kwargs)
|
||||
|
||||
|
||||
# pylint: disable=C0103,C0116
|
||||
def http_PUT(*args, **kwargs):
|
||||
return http_request("PUT", *args, **kwargs)
|
||||
|
||||
|
||||
# pylint: disable=C0103,C0116
|
||||
def http_DELETE(*args, **kwargs):
|
||||
return http_request("DELETE", *args, **kwargs)
|
||||
|
||||
|
||||
class AuthHandlerBase:
|
||||
def _get_auth_schemes(self, response):
|
||||
"""
|
||||
Extract all `www-authenticate` headers from `response` and return them
|
||||
in a dictionary: `{scheme: auth_method}`.
|
||||
"""
|
||||
result = {}
|
||||
for auth_method in response.headers.get_all("www-authenticate", []):
|
||||
scheme = auth_method.split()[0].lower()
|
||||
result[scheme] = auth_method
|
||||
return result
|
||||
|
||||
def set_request_headers(self, url, request_headers):
|
||||
"""
|
||||
Modify request headers with auth headers.
|
||||
|
||||
:param url: Request URL provides context for `request_headers` modifications
|
||||
:type url: str
|
||||
:param request_headers: object to be modified
|
||||
:type request_headers: urllib3.response.HTTPHeaderDict
|
||||
:return: `True` on if `request_headers` was modified, `False` otherwise
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def set_request_headers_after_401(self, url, request_headers, response):
|
||||
"""
|
||||
Modify request headers with auth headers after getting 401 response.
|
||||
|
||||
:param url: Request URL provides context for `request_headers` modifications
|
||||
:type url: str
|
||||
:param request_headers: object to be modified
|
||||
:type request_headers: urllib3.response.HTTPHeaderDict
|
||||
:param response: Response object provides context for `request_headers` modifications
|
||||
:type response: urllib3.response.HTTPResponse
|
||||
:return: `True` on if `request_headers` was modified, `False` otherwise
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def process_response(self, url, request_headers, response):
|
||||
"""
|
||||
Retrieve data from response, save cookies, etc.
|
||||
|
||||
:param url: Request URL provides context for `request_headers` modifications
|
||||
:type url: str
|
||||
:param request_headers: object to be modified
|
||||
:type request_headers: urllib3.response.HTTPHeaderDict
|
||||
:param response: Response object provides context for `request_headers` modifications
|
||||
:type response: urllib3.response.HTTPResponse
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class CookieJarAuthHandler(AuthHandlerBase):
|
||||
# Shared among instances, instantiate on first use, key equals too cookiejar path.
|
||||
COOKIEJARS = {}
|
||||
|
||||
def __init__(self, cookiejar_path):
|
||||
self.cookiejar_path = cookiejar_path
|
||||
|
||||
@property
|
||||
def _cookiejar(self):
|
||||
jar = self.COOKIEJARS.get(self.cookiejar_path, None)
|
||||
if not jar:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.cookiejar_path), mode=0o700)
|
||||
except OSError as e:
|
||||
if e.errno != errno.EEXIST:
|
||||
raise
|
||||
jar = http.cookiejar.LWPCookieJar(self.cookiejar_path)
|
||||
if os.path.isfile(self.cookiejar_path):
|
||||
jar.load()
|
||||
self.COOKIEJARS[self.cookiejar_path] = jar
|
||||
return jar
|
||||
|
||||
def set_request_headers(self, url, request_headers):
|
||||
self._cookiejar.add_cookie_header(MockRequest(url, request_headers))
|
||||
return bool(request_headers.get_all("cookie", None))
|
||||
|
||||
def set_request_headers_after_401(self, url, request_headers, response):
|
||||
# can't do anything, we have tried setting a cookie already
|
||||
return False
|
||||
|
||||
def process_response(self, url, request_headers, response):
|
||||
self._cookiejar.extract_cookies(response, MockRequest(url, response.headers))
|
||||
self._cookiejar.save()
|
||||
|
||||
|
||||
class BasicAuthHandler(AuthHandlerBase):
|
||||
def __init__(self, user, password):
|
||||
self.user = user
|
||||
self.password = password
|
||||
|
||||
def set_request_headers(self, url, request_headers):
|
||||
return False
|
||||
|
||||
def set_request_headers_after_401(self, url, request_headers, response):
|
||||
auth_schemes = self._get_auth_schemes(response)
|
||||
if "basic" not in auth_schemes:
|
||||
return False
|
||||
if not self.user or not self.password:
|
||||
return False
|
||||
request_headers.update(urllib3.make_headers(basic_auth=f"{self.user}:{self.password}"))
|
||||
return True
|
||||
|
||||
def process_response(self, url, request_headers, response):
|
||||
pass
|
||||
|
||||
|
||||
class SignatureAuthHandler(AuthHandlerBase):
|
||||
def __init__(self, user, sshkey, basic_auth_password=None):
|
||||
self.user = user
|
||||
self.sshkey = sshkey
|
||||
# value of `basic_auth_password` is only used as a hint if we should skip signature auth
|
||||
self.basic_auth_password = bool(basic_auth_password)
|
||||
|
||||
def list_ssh_agent_keys(self):
|
||||
cmd = ['ssh-add', '-l']
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
except OSError:
|
||||
# ssh-add is not available
|
||||
return []
|
||||
stdout, _ = proc.communicate()
|
||||
if proc.returncode == 0 and stdout.strip():
|
||||
return [self.get_fingerprint(line) for line in stdout.splitlines()]
|
||||
else:
|
||||
return []
|
||||
|
||||
def is_ssh_private_keyfile(self, keyfile_path):
|
||||
if not os.path.isfile(keyfile_path):
|
||||
return False
|
||||
with open(keyfile_path, "r") as f:
|
||||
try:
|
||||
line = f.readline(100).strip()
|
||||
except UnicodeDecodeError:
|
||||
# skip binary files
|
||||
return False
|
||||
if line == "-----BEGIN RSA PRIVATE KEY-----":
|
||||
return True
|
||||
if line == "-----BEGIN OPENSSH PRIVATE KEY-----":
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_ssh_public_keyfile(self, keyfile_path):
|
||||
if not os.path.isfile(keyfile_path):
|
||||
return False
|
||||
return keyfile_path.endswith(".pub")
|
||||
|
||||
@staticmethod
|
||||
def get_fingerprint(line):
|
||||
parts = line.strip().split(b" ")
|
||||
if len(parts) < 2:
|
||||
raise ValueError("Unable to retrieve ssh key fingerprint from line: {}".format(line))
|
||||
return parts[1]
|
||||
|
||||
def list_ssh_dir_keys(self):
|
||||
sshdir = os.path.expanduser('~/.ssh')
|
||||
keys_in_home_ssh = {}
|
||||
for keyfile in os.listdir(sshdir):
|
||||
if keyfile.startswith(("agent-", "authorized_keys", "config", "known_hosts")):
|
||||
# skip files that definitely don't contain keys
|
||||
continue
|
||||
|
||||
keyfile_path = os.path.join(sshdir, keyfile)
|
||||
# public key alone may be sufficient because the private key
|
||||
# can get loaded into ssh-agent from gpg (yubikey works this way)
|
||||
is_public = self.is_ssh_public_keyfile(keyfile_path)
|
||||
# skip private detection if we think the key is a public one already
|
||||
is_private = False if is_public else self.is_ssh_private_keyfile(keyfile_path)
|
||||
|
||||
if not is_public and not is_private:
|
||||
continue
|
||||
|
||||
cmd = ["ssh-keygen", "-lf", keyfile_path]
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
stdout, _ = proc.communicate()
|
||||
if proc.returncode == 0:
|
||||
fingerprint = self.get_fingerprint(stdout)
|
||||
if fingerprint and (fingerprint not in keys_in_home_ssh or is_private):
|
||||
# prefer path to a private key
|
||||
keys_in_home_ssh[fingerprint] = keyfile_path
|
||||
return keys_in_home_ssh
|
||||
|
||||
def guess_keyfile(self):
|
||||
keys_in_agent = self.list_ssh_agent_keys()
|
||||
if keys_in_agent:
|
||||
keys_in_home_ssh = self.list_ssh_dir_keys()
|
||||
for fingerprint in keys_in_agent:
|
||||
if fingerprint in keys_in_home_ssh:
|
||||
return keys_in_home_ssh[fingerprint]
|
||||
sshdir = os.path.expanduser('~/.ssh')
|
||||
keyfiles = ('id_ed25519', 'id_ed25519_sk', 'id_rsa', 'id_ecdsa', 'id_ecdsa_sk', 'id_dsa')
|
||||
for keyfile in keyfiles:
|
||||
keyfile_path = os.path.join(sshdir, keyfile)
|
||||
if os.path.isfile(keyfile_path):
|
||||
return keyfile_path
|
||||
return None
|
||||
|
||||
def ssh_sign(self, data, namespace, keyfile=None):
|
||||
try:
|
||||
data = bytes(data, 'utf-8')
|
||||
except:
|
||||
pass
|
||||
if not keyfile:
|
||||
keyfile = self.guess_keyfile()
|
||||
else:
|
||||
if '/' not in keyfile:
|
||||
keyfile = '~/.ssh/' + keyfile
|
||||
keyfile = os.path.expanduser(keyfile)
|
||||
|
||||
cmd = ['ssh-keygen', '-Y', 'sign', '-f', keyfile, '-n', namespace, '-q']
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||
stdout, _ = proc.communicate(data)
|
||||
if proc.returncode:
|
||||
raise oscerr.OscIOError(None, 'ssh-keygen signature creation failed: %d' % proc.returncode)
|
||||
|
||||
signature = decode_it(stdout)
|
||||
match = re.match(r"\A-----BEGIN SSH SIGNATURE-----\n(.*)\n-----END SSH SIGNATURE-----", signature, re.S)
|
||||
if not match:
|
||||
raise oscerr.OscIOError(None, 'could not extract ssh signature')
|
||||
return base64.b64decode(match.group(1))
|
||||
|
||||
def get_authorization(self, chal):
|
||||
realm = chal.get('realm', '')
|
||||
now = int(time.time())
|
||||
sigdata = "(created): %d" % now
|
||||
signature = self.ssh_sign(sigdata, realm, self.sshkey)
|
||||
signature = decode_it(base64.b64encode(signature))
|
||||
return 'keyId="%s",algorithm="ssh",headers="(created)",created=%d,signature="%s"' \
|
||||
% (self.user, now, signature)
|
||||
|
||||
def add_signature_auth_header(self, req, auth):
|
||||
token, challenge = auth.split(' ', 1)
|
||||
chal = urllib.request.parse_keqv_list(filter(None, urllib.request.parse_http_list(challenge)))
|
||||
auth = self.get_authorization(chal)
|
||||
if not auth:
|
||||
return False
|
||||
auth_val = 'Signature %s' % auth
|
||||
req.add('Authorization', auth_val)
|
||||
return True
|
||||
|
||||
def set_request_headers(self, url, request_headers):
|
||||
return False
|
||||
|
||||
def set_request_headers_after_401(self, url, request_headers, response):
|
||||
auth_schemes = self._get_auth_schemes(response)
|
||||
|
||||
if "signature" not in auth_schemes:
|
||||
# unsupported on server
|
||||
return False
|
||||
|
||||
if not self.user:
|
||||
return False
|
||||
|
||||
if self.basic_auth_password and "basic" in auth_schemes:
|
||||
# prefer basic auth, but only if password is set
|
||||
return False
|
||||
|
||||
if not self.sshkey_known():
|
||||
# ssh key not set, try to guess it
|
||||
self.sshkey = self.guess_keyfile()
|
||||
|
||||
if not self.sshkey_known():
|
||||
# ssh key cannot be guessed
|
||||
return False
|
||||
|
||||
return self.add_signature_auth_header(request_headers, auth_schemes["signature"])
|
||||
|
||||
def process_response(self, url, request_headers, response):
|
||||
pass
|
||||
|
||||
def sshkey_known(self):
|
||||
return self.sshkey is not None
|
102
osc/core.py
102
osc/core.py
@ -34,21 +34,11 @@ try:
|
||||
except ImportError:
|
||||
distro = None
|
||||
|
||||
try:
|
||||
from urllib.parse import urlsplit, urlunsplit, urlparse, quote_plus, urlencode, unquote
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import pathname2url, install_opener, urlopen
|
||||
from urllib.request import Request as URLRequest
|
||||
from io import StringIO
|
||||
from http.client import IncompleteRead
|
||||
except ImportError:
|
||||
#python 2.x
|
||||
from urlparse import urlsplit, urlunsplit, urlparse
|
||||
from urllib import pathname2url, quote_plus, urlencode, unquote
|
||||
from urllib2 import HTTPError, URLError, install_opener, urlopen
|
||||
from urllib2 import Request as URLRequest
|
||||
from cStringIO import StringIO
|
||||
from httplib import IncompleteRead
|
||||
from urllib.parse import urlsplit, urlunsplit, urlparse, quote_plus, urlencode, unquote
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import pathname2url
|
||||
from io import StringIO
|
||||
from http.client import IncompleteRead
|
||||
|
||||
try:
|
||||
# Works up to Python 3.8, needed for Python < 3.3 (inc 2.7)
|
||||
@ -60,6 +50,7 @@ except ImportError:
|
||||
|
||||
from . import oscerr
|
||||
from . import conf
|
||||
from .connection import http_request, http_GET, http_POST, http_PUT, http_DELETE
|
||||
|
||||
try:
|
||||
from functools import cmp_to_key
|
||||
@ -3382,87 +3373,6 @@ def makeurl(baseurl, l, query=[]):
|
||||
return urlunsplit((scheme, netloc, '/'.join([path] + list(l)), query, ''))
|
||||
|
||||
|
||||
def http_request(method, url, headers={}, data=None, file=None):
|
||||
"""wrapper around urllib2.urlopen for error handling,
|
||||
and to support additional (PUT, DELETE) methods"""
|
||||
class DataContext:
|
||||
"""Wrap a data value (or None) in a context manager."""
|
||||
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def __enter__(self):
|
||||
return self._data
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
return None
|
||||
|
||||
|
||||
if file is not None and data is not None:
|
||||
raise RuntimeError('file and data are mutually exclusive')
|
||||
|
||||
if conf.config['http_debug']:
|
||||
print('\n\n--', method, url, file=sys.stderr)
|
||||
|
||||
if method == 'POST' and not file and not data:
|
||||
# adding data to an urllib2 request transforms it into a POST
|
||||
data = b''
|
||||
|
||||
req = URLRequest(url)
|
||||
api_host_options = {}
|
||||
apiurl = conf.extract_known_apiurl(url)
|
||||
if apiurl is not None:
|
||||
# ok no external request
|
||||
install_opener(conf._build_opener(apiurl))
|
||||
api_host_options = conf.get_apiurl_api_host_options(apiurl)
|
||||
for header, value in api_host_options['http_headers']:
|
||||
req.add_header(header, value)
|
||||
|
||||
req.get_method = lambda: method
|
||||
|
||||
# POST requests are application/x-www-form-urlencoded per default
|
||||
# but sending data requires an octet-stream type
|
||||
if method == 'PUT' or (method == 'POST' and (data or file)):
|
||||
req.add_header('Content-Type', 'application/octet-stream')
|
||||
|
||||
if isinstance(headers, type({})):
|
||||
for i in headers.keys():
|
||||
print(headers[i])
|
||||
req.add_header(i, headers[i])
|
||||
|
||||
if conf.config['debug']: print(method, url, file=sys.stderr)
|
||||
|
||||
content_length = None
|
||||
if data is not None:
|
||||
if isinstance(data, str):
|
||||
data = data.encode('utf-8')
|
||||
content_length = len(data)
|
||||
elif file is not None:
|
||||
content_length = os.path.getsize(file)
|
||||
|
||||
with (open(file, 'rb') if file is not None else DataContext(data)) as d:
|
||||
req.data = d
|
||||
if content_length is not None:
|
||||
# do this after setting req.data because the corresponding setter
|
||||
# kills an existing Content-Length header (see urllib.Request class
|
||||
# (python38))
|
||||
req.add_header('Content-Length', str(content_length))
|
||||
try:
|
||||
return urlopen(req)
|
||||
except URLError as e:
|
||||
e._osc_host_port = req.host
|
||||
raise
|
||||
finally:
|
||||
if hasattr(conf.cookiejar, 'save'):
|
||||
conf.cookiejar.save(ignore_discard=True)
|
||||
|
||||
|
||||
def http_GET(*args, **kwargs): return http_request('GET', *args, **kwargs)
|
||||
def http_POST(*args, **kwargs): return http_request('POST', *args, **kwargs)
|
||||
def http_PUT(*args, **kwargs): return http_request('PUT', *args, **kwargs)
|
||||
def http_DELETE(*args, **kwargs): return http_request('DELETE', *args, **kwargs)
|
||||
|
||||
|
||||
def check_store_version(dir):
|
||||
global store
|
||||
|
||||
|
521
osc/oscssl.py
521
osc/oscssl.py
@ -1,414 +1,161 @@
|
||||
# Copyright (C) 2009 Novell Inc.
|
||||
# This program is free software; it may be used, copied, modified
|
||||
# and distributed under the terms of the GNU General Public Licence,
|
||||
# either version 2, or (at your option) any later version.
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
from M2Crypto.SSL.Checker import SSLVerificationError
|
||||
from M2Crypto import m2, SSL, httpslib
|
||||
import M2Crypto.m2urllib2
|
||||
import binascii
|
||||
import datetime
|
||||
import errno
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import inspect
|
||||
import tempfile
|
||||
import typing
|
||||
|
||||
try:
|
||||
from urllib.parse import urlparse, splithost, splitport, splittype, urldefrag
|
||||
from urllib.request import addinfourl
|
||||
from http.client import HTTPSConnection
|
||||
except ImportError:
|
||||
#python 2.x
|
||||
from urlparse import urlparse, urldefrag
|
||||
from urllib import addinfourl, splithost, splitport, splittype
|
||||
from httplib import HTTPSConnection
|
||||
import urllib3.connection
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from urllib3.util.ssl_ import create_urllib3_context
|
||||
|
||||
from . import oscerr
|
||||
|
||||
|
||||
# based on openssl's include/openssl/x509_vfy.h.in
|
||||
X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT = 18
|
||||
X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN = 19
|
||||
|
||||
|
||||
def create_ssl_context():
|
||||
"""
|
||||
Create a ssl context with disabled weak crypto.
|
||||
|
||||
Relatively safe defaults are set in urllib3 already,
|
||||
but we restrict crypto even more.
|
||||
"""
|
||||
ssl_context = create_urllib3_context()
|
||||
ssl_context.options |= ssl.OP_NO_SSLv2
|
||||
ssl_context.options |= ssl.OP_NO_SSLv3
|
||||
ssl_context.options |= ssl.OP_NO_TLSv1
|
||||
ssl_context.options |= ssl.OP_NO_TLSv1_1
|
||||
return ssl_context
|
||||
|
||||
|
||||
class CertVerificationError(oscerr.OscBaseError):
|
||||
def __str__(self):
|
||||
args_str = [str(i) for i in self.args]
|
||||
return "Certificate Verification Error: " + "\n".join(args_str)
|
||||
|
||||
from .core import raw_input
|
||||
|
||||
class TrustedCertStore:
|
||||
_tmptrusted = {}
|
||||
|
||||
def __init__(self, host, port, app, cert):
|
||||
|
||||
self.cert = cert
|
||||
def __init__(self, ssl_context, host, port):
|
||||
self.ssl_context = ssl_context
|
||||
self.host = host
|
||||
if self.host == None:
|
||||
raise Exception("empty host")
|
||||
if port:
|
||||
self.host += "_%d" % port
|
||||
import os
|
||||
self.dir = os.path.expanduser('~/.config/%s/trusted-certs' % app)
|
||||
self.file = self.dir + '/%s.pem' % self.host
|
||||
self.port = port or 443
|
||||
|
||||
def is_known(self):
|
||||
if self.host in self._tmptrusted:
|
||||
return True
|
||||
if not self.host:
|
||||
raise ValueError("Empty `host`")
|
||||
|
||||
import os
|
||||
if os.path.exists(self.file):
|
||||
return True
|
||||
return False
|
||||
file_name = f"{self.host}_{self.port}"
|
||||
self.dir_path = os.path.expanduser("~/.config/osc/trusted-certs")
|
||||
self.pem_path = os.path.join(self.dir_path, f"{file_name}.pem")
|
||||
|
||||
def is_trusted(self):
|
||||
import os
|
||||
if self.host in self._tmptrusted:
|
||||
cert = self._tmptrusted[self.host]
|
||||
if os.path.isfile(self.pem_path):
|
||||
# load permanently trusted certificate that is stored on disk
|
||||
with open(self.pem_path, "rb") as f:
|
||||
self.cert = x509.load_pem_x509_certificate(f.read())
|
||||
self.ssl_context.load_verify_locations(cafile=self.pem_path)
|
||||
else:
|
||||
if not os.path.exists(self.file):
|
||||
return False
|
||||
from M2Crypto import X509
|
||||
cert = X509.load_cert(self.file)
|
||||
if self.cert.as_pem() == cert.as_pem():
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
self.cert = None
|
||||
|
||||
def trust_tmp(self):
|
||||
self._tmptrusted[self.host] = self.cert
|
||||
def get_server_certificate(self):
|
||||
# The following code throws an exception on self-signed certs,
|
||||
# therefore we need to retrieve the cert differently.
|
||||
# pem = ssl.get_server_certificate((self.host, self.port))
|
||||
|
||||
def trust_always(self):
|
||||
self.trust_tmp()
|
||||
from M2Crypto import X509
|
||||
import os
|
||||
if not os.path.exists(self.dir):
|
||||
os.makedirs(self.dir)
|
||||
self.cert.save_pem(self.file)
|
||||
ssl_context = create_ssl_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
sock = ssl_context.wrap_socket(socket.socket(), server_hostname=self.host)
|
||||
sock.connect((self.host, self.port))
|
||||
der = sock.getpeercert(binary_form=True)
|
||||
pem = ssl.DER_cert_to_PEM_cert(der)
|
||||
cert = x509.load_pem_x509_certificate(pem.encode("utf-8"))
|
||||
return cert
|
||||
|
||||
|
||||
# verify_cb is called for each error once
|
||||
# we only collect the errors and return suceess
|
||||
# connection will be aborted later if it needs to
|
||||
def verify_cb(ctx, ok, store):
|
||||
if not ctx.verrs:
|
||||
ctx.verrs = ValidationErrors()
|
||||
|
||||
try:
|
||||
if not ok:
|
||||
ctx.verrs.record(store.get_current_cert(), store.get_error(), store.get_error_depth())
|
||||
return 1
|
||||
|
||||
except Exception as e:
|
||||
print(e, file=sys.stderr)
|
||||
return 0
|
||||
|
||||
class FailCert:
|
||||
def __init__(self, cert):
|
||||
def trust_permanently(self, cert):
|
||||
"""
|
||||
Permanently trust the certificate.
|
||||
Store it as a pem file in ~/.config/osc/trusted-certs.
|
||||
"""
|
||||
self.cert = cert
|
||||
self.errs = []
|
||||
data = self.cert.public_bytes(serialization.Encoding.PEM)
|
||||
with open(self.pem_path, "wb") as f:
|
||||
f.write(data)
|
||||
self.ssl_context.load_verify_locations(cafile=self.pem_path)
|
||||
|
||||
class ValidationErrors:
|
||||
def trust_temporarily(self, cert):
|
||||
"""
|
||||
Temporarily trust the certificate.
|
||||
"""
|
||||
self.cert = cert
|
||||
tmp_dir = os.path.expanduser("~/.config/osc")
|
||||
data = self.cert.public_bytes(serialization.Encoding.PEM)
|
||||
with tempfile.NamedTemporaryFile(mode="wb+", dir=tmp_dir, prefix="temp_trusted_cert_") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
self.ssl_context.load_verify_locations(cafile=f.name)
|
||||
|
||||
def __init__(self):
|
||||
self.chain_ok = True
|
||||
self.cert_ok = True
|
||||
self.failures = {}
|
||||
|
||||
def record(self, cert, err, depth):
|
||||
#print "cert for %s, level %d fail(%d)" % ( cert.get_subject().commonName, depth, err )
|
||||
if depth == 0:
|
||||
self.cert_ok = False
|
||||
else:
|
||||
self.chain_ok = False
|
||||
|
||||
if not depth in self.failures:
|
||||
self.failures[depth] = FailCert(cert)
|
||||
else:
|
||||
if self.failures[depth].cert.get_fingerprint() != cert.get_fingerprint():
|
||||
raise Exception("Certificate changed unexpectedly. This should not happen")
|
||||
self.failures[depth].errs.append(err)
|
||||
|
||||
def show(self, out):
|
||||
for depth in self.failures.keys():
|
||||
cert = self.failures[depth].cert
|
||||
print("*** certificate verify failed at depth %d" % depth, file=out)
|
||||
print("Subject: ", cert.get_subject(), file=out)
|
||||
print("Issuer: ", cert.get_issuer(), file=out)
|
||||
print("Valid: ", cert.get_not_before(), "-", cert.get_not_after(), file=out)
|
||||
print("Fingerprint(MD5): ", cert.get_fingerprint('md5'), file=out)
|
||||
print("Fingerprint(SHA1): ", cert.get_fingerprint('sha1'), file=out)
|
||||
|
||||
for err in self.failures[depth].errs:
|
||||
reason = "Unknown"
|
||||
try:
|
||||
import M2Crypto.Err
|
||||
reason = M2Crypto.Err.get_x509_verify_error(err)
|
||||
except:
|
||||
pass
|
||||
print("Reason:", reason, file=out)
|
||||
|
||||
# check if the encountered errors could be ignored
|
||||
def could_ignore(self):
|
||||
if not 0 in self.failures:
|
||||
return True
|
||||
|
||||
nonfatal_errors = [
|
||||
m2.X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY,
|
||||
m2.X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN,
|
||||
m2.X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT,
|
||||
m2.X509_V_ERR_CERT_UNTRUSTED,
|
||||
m2.X509_V_ERR_UNABLE_TO_VERIFY_LEAF_SIGNATURE,
|
||||
|
||||
m2.X509_V_ERR_CERT_NOT_YET_VALID,
|
||||
m2.X509_V_ERR_CERT_HAS_EXPIRED,
|
||||
m2.X509_V_OK,
|
||||
]
|
||||
|
||||
canignore = True
|
||||
for err in self.failures[0].errs:
|
||||
if not err in nonfatal_errors:
|
||||
canignore = False
|
||||
break
|
||||
|
||||
return canignore
|
||||
|
||||
class mySSLContext(SSL.Context):
|
||||
|
||||
def __init__(self):
|
||||
SSL.Context.__init__(self, 'sslv23')
|
||||
self.set_options(m2.SSL_OP_NO_SSLv2 | m2.SSL_OP_NO_SSLv3)
|
||||
self.set_cipher_list("ECDHE-RSA-AES128-SHA256:AES128-GCM-SHA256:RC4:HIGH:!MD5:!aNULL:!EDH")
|
||||
self.set_session_cache_mode(m2.SSL_SESS_CACHE_CLIENT)
|
||||
self.verrs = None
|
||||
#self.set_info_callback() # debug
|
||||
self.set_verify(SSL.verify_peer | SSL.verify_fail_if_no_peer_cert, depth=9, callback=lambda ok, store: verify_cb(self, ok, store))
|
||||
|
||||
class myHTTPSHandler(M2Crypto.m2urllib2.HTTPSHandler):
|
||||
handler_order = 499
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.appname = kwargs.pop('appname', 'generic')
|
||||
M2Crypto.m2urllib2.HTTPSHandler.__init__(self, *args, **kwargs)
|
||||
|
||||
# copied from M2Crypto.m2urllib2.HTTPSHandler
|
||||
# it's sole purpose is to use our myHTTPSHandler/myHTTPSProxyHandler class
|
||||
# ideally the m2urllib2.HTTPSHandler.https_open() method would be split into
|
||||
# "do_open()" and "https_open()" so that we just need to override
|
||||
# the small "https_open()" method...)
|
||||
def https_open(self, req):
|
||||
# https://docs.python.org/3.3/library/urllib.request.html#urllib.request.Request.get_host
|
||||
try: # up to python-3.2
|
||||
host = req.get_host()
|
||||
except AttributeError: # from python-3.3
|
||||
host = req.host
|
||||
if not host:
|
||||
raise M2Crypto.m2urllib2.URLError('no host given')
|
||||
|
||||
# Our change: Check to see if we're using a proxy.
|
||||
# Then create an appropriate ssl-aware connection.
|
||||
full_url = req.get_full_url()
|
||||
target_host = urlparse(full_url)[1]
|
||||
|
||||
if target_host != host:
|
||||
request_uri = urldefrag(full_url)[0]
|
||||
h = myProxyHTTPSConnection(host=host, appname=self.appname, ssl_context=self.ctx)
|
||||
else:
|
||||
try: # up to python-3.2
|
||||
request_uri = req.get_selector()
|
||||
except AttributeError: # from python-3.3
|
||||
request_uri = req.selector
|
||||
h = myHTTPSConnection(host=host, appname=self.appname, ssl_context=self.ctx)
|
||||
# End our change
|
||||
h.set_debuglevel(self._debuglevel)
|
||||
|
||||
headers = dict(req.headers)
|
||||
headers.update(req.unredirected_hdrs)
|
||||
# We want to make an HTTP/1.1 request, but the addinfourl
|
||||
# class isn't prepared to deal with a persistent connection.
|
||||
# It will try to read all remaining data from the socket,
|
||||
# which will block while the server waits for the next request.
|
||||
# So make sure the connection gets closed after the (only)
|
||||
# request.
|
||||
headers["Connection"] = "close"
|
||||
@staticmethod
|
||||
def _display_cert(cert):
|
||||
print("Subject:", cert.subject.rfc4514_string())
|
||||
print("Issuer:", cert.issuer.rfc4514_string())
|
||||
try:
|
||||
h.request(req.get_method(), request_uri, req.data, headers)
|
||||
r = h.getresponse()
|
||||
except socket.error as err: # XXX what error?
|
||||
raise M2Crypto.m2urllib2.URLError(err)
|
||||
san_ext = cert.extensions.get_extension_for_oid(x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
|
||||
san_ext_value = typing.cast(x509.SubjectAlternativeName, san_ext.value)
|
||||
san_ext_dnsnames = san_ext_value.get_values_for_type(x509.DNSName)
|
||||
except x509.extensions.ExtensionNotFound:
|
||||
san_ext_dnsnames = ["(not available)"]
|
||||
for san in san_ext_dnsnames:
|
||||
print("subjectAltName:", san)
|
||||
print("Valid:", cert.not_valid_before, "->", cert.not_valid_after)
|
||||
print("Fingerprint(MD5):", binascii.hexlify(cert.fingerprint(hashes.MD5())).decode("utf-8"))
|
||||
print("Fingerprint(SHA1):", binascii.hexlify(cert.fingerprint(hashes.SHA1())).decode("utf-8"))
|
||||
|
||||
# Pick apart the HTTPResponse object to get the addinfourl
|
||||
# object initialized properly.
|
||||
|
||||
# Wrap the HTTPResponse object in socket's file object adapter
|
||||
# for Windows. That adapter calls recv(), so delegate recv()
|
||||
# to read(). This weird wrapping allows the returned object to
|
||||
# have readline() and readlines() methods.
|
||||
r.recv = r.read
|
||||
if (sys.version_info < (3, 0)):
|
||||
fp = socket._fileobject(r, close=True)
|
||||
def prompt_trust(self, cert, reason):
|
||||
if self.cert:
|
||||
# check if the certificate matches the already trusted certificate for the host and port
|
||||
if cert != self.cert:
|
||||
raise CertVerificationError([
|
||||
"Remote host identification has changed",
|
||||
"",
|
||||
"WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!",
|
||||
"IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!",
|
||||
"",
|
||||
f"Offending certificate is at '{self.pem_path}'"
|
||||
])
|
||||
else:
|
||||
r._decref_socketios = lambda: None
|
||||
r.ssl = h.sock.ssl
|
||||
r._timeout = -1.0
|
||||
# hack to bypass python3 bug with 0 buffer size and
|
||||
# http/client.py readinto method for response class
|
||||
if r.length is not None and r.length == 0:
|
||||
r.readinto = lambda b: 0
|
||||
r.recv_into = r.readinto
|
||||
fp = socket.SocketIO(r, 'rb')
|
||||
# since there is no trusted certificate on disk,
|
||||
# let's display the server cert and give user options to trust it
|
||||
print("The server certificate failed verification")
|
||||
print()
|
||||
self._display_cert(cert)
|
||||
print(f"Reason: {reason}")
|
||||
|
||||
resp = addinfourl(fp, r.msg, req.get_full_url())
|
||||
resp.code = r.status
|
||||
resp.msg = r.reason
|
||||
return resp
|
||||
|
||||
|
||||
class myHTTPSConnection(M2Crypto.httpslib.HTTPSConnection):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.appname = kwargs.pop('appname', 'generic')
|
||||
M2Crypto.httpslib.HTTPSConnection.__init__(self, *args, **kwargs)
|
||||
|
||||
def _connect(self, family):
|
||||
# workaround for old M2Crypto versions where the the
|
||||
# SSL.Connection.__init__ constructor has no "family" parameter
|
||||
kwargs = {}
|
||||
argspec = inspect.getargspec(SSL.Connection.__init__)
|
||||
if 'family' in argspec.args:
|
||||
kwargs['family'] = family
|
||||
elif family != socket.AF_INET:
|
||||
# old SSL.Connection classes implicitly use socket.AF_INET
|
||||
return False
|
||||
|
||||
self.sock = SSL.Connection(self.ssl_ctx, **kwargs)
|
||||
if self.session:
|
||||
self.sock.set_session(self.session)
|
||||
if hasattr(self.sock, 'set_tlsext_host_name'):
|
||||
self.sock.set_tlsext_host_name(self.host)
|
||||
self.sock.connect((self.host, self.port))
|
||||
return True
|
||||
|
||||
def connect(self):
|
||||
# based on M2Crypto.httpslib.HTTPSConnection.connect
|
||||
last_exc = None
|
||||
connected = False
|
||||
for addrinfo in socket.getaddrinfo(self.host, self.port,
|
||||
socket.AF_UNSPEC,
|
||||
socket.SOCK_STREAM,
|
||||
0, 0):
|
||||
try:
|
||||
connected = self._connect(addrinfo[0])
|
||||
if connected:
|
||||
break
|
||||
except socket.error as e:
|
||||
last_exc = e
|
||||
finally:
|
||||
if not connected and self.sock is not None:
|
||||
self.sock.close()
|
||||
if not connected:
|
||||
if last_exc is None:
|
||||
msg = 'getaddrinfo returned empty list or unsupported families'
|
||||
raise RuntimeError(msg)
|
||||
raise last_exc
|
||||
# ok we are connected, verify cert
|
||||
verify_certificate(self)
|
||||
|
||||
def getHost(self):
|
||||
return self.host
|
||||
|
||||
def getPort(self):
|
||||
return self.port
|
||||
|
||||
class myProxyHTTPSConnection(M2Crypto.httpslib.ProxyHTTPSConnection, HTTPSConnection):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.appname = kwargs.pop('appname', 'generic')
|
||||
M2Crypto.httpslib.ProxyHTTPSConnection.__init__(self, *args, **kwargs)
|
||||
|
||||
def _start_ssl(self):
|
||||
M2Crypto.httpslib.ProxyHTTPSConnection._start_ssl(self)
|
||||
verify_certificate(self)
|
||||
|
||||
def endheaders(self, *args, **kwargs):
|
||||
if self._proxy_auth is None:
|
||||
self._proxy_auth = self._encode_auth()
|
||||
HTTPSConnection.endheaders(self, *args, **kwargs)
|
||||
|
||||
# broken in m2crypto: port needs to be an int
|
||||
def putrequest(self, method, url, skip_host=0, skip_accept_encoding=0):
|
||||
#putrequest is called before connect, so can interpret url and get
|
||||
#real host/port to be used to make CONNECT request to proxy
|
||||
proto, rest = splittype(url)
|
||||
if proto is None:
|
||||
raise ValueError("unknown URL type: %s" % url)
|
||||
#get host
|
||||
host, rest = splithost(rest)
|
||||
#try to get port
|
||||
host, port = splitport(host)
|
||||
#if port is not defined try to get from proto
|
||||
if port is None:
|
||||
try:
|
||||
port = self._ports[proto]
|
||||
except KeyError:
|
||||
raise ValueError("unknown protocol for: %s" % url)
|
||||
self._real_host = host
|
||||
self._real_port = int(port)
|
||||
M2Crypto.httpslib.HTTPSConnection.putrequest(self, method, url, skip_host, skip_accept_encoding)
|
||||
|
||||
def getHost(self):
|
||||
return self._real_host
|
||||
|
||||
def getPort(self):
|
||||
return self._real_port
|
||||
|
||||
def verify_certificate(connection):
|
||||
ctx = connection.sock.ctx
|
||||
verrs = ctx.verrs
|
||||
ctx.verrs = None
|
||||
cert = connection.sock.get_peer_cert()
|
||||
if not cert:
|
||||
connection.close()
|
||||
raise SSLVerificationError("server did not present a certificate")
|
||||
|
||||
# XXX: should be check if the certificate is known anyways?
|
||||
# Maybe it changed to something valid.
|
||||
if not connection.sock.verify_ok():
|
||||
|
||||
tc = TrustedCertStore(connection.getHost(), connection.getPort(), connection.appname, cert)
|
||||
|
||||
if tc.is_known():
|
||||
|
||||
if tc.is_trusted(): # ok, same cert as the stored one
|
||||
return
|
||||
else:
|
||||
print("WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!", file=sys.stderr)
|
||||
print("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!", file=sys.stderr)
|
||||
print("offending certificate is at '%s'" % tc.file, file=sys.stderr)
|
||||
raise SSLVerificationError("remote host identification has changed")
|
||||
|
||||
# if http_debug is set we redirect sys.stdout to an StringIO
|
||||
# instance in order to do some header filtering (see conf module)
|
||||
# so we have to use the "original" stdout for printing
|
||||
out = getattr(connection, '_orig_stdout', sys.stdout)
|
||||
verrs.show(out)
|
||||
|
||||
print(file=out)
|
||||
|
||||
if not verrs.could_ignore():
|
||||
raise SSLVerificationError("Certificate validation error cannot be ignored")
|
||||
|
||||
if not verrs.chain_ok:
|
||||
print("A certificate in the chain failed verification", file=out)
|
||||
if not verrs.cert_ok:
|
||||
print("The server certificate failed verification", file=out)
|
||||
|
||||
while True:
|
||||
print("""
|
||||
while True:
|
||||
print("""
|
||||
Would you like to
|
||||
0 - quit (default)
|
||||
1 - continue anyways
|
||||
2 - trust the server certificate permanently
|
||||
9 - review the server certificate
|
||||
""", file=out)
|
||||
""")
|
||||
|
||||
print("Enter choice [0129]: ", end='', file=out)
|
||||
r = raw_input()
|
||||
if not r or r == '0':
|
||||
connection.close()
|
||||
raise SSLVerificationError("Untrusted Certificate")
|
||||
elif r == '1':
|
||||
tc.trust_tmp()
|
||||
return
|
||||
elif r == '2':
|
||||
tc.trust_always()
|
||||
return
|
||||
elif r == '9':
|
||||
print(cert.as_text(), file=out)
|
||||
|
||||
# vim: sw=4 et
|
||||
print("Enter choice [0129]: ", end="")
|
||||
r = input()
|
||||
if not r or r == "0":
|
||||
raise CertVerificationError(["Untrusted certificate"])
|
||||
elif r == "1":
|
||||
self.trust_temporarily(cert)
|
||||
return
|
||||
elif r == "2":
|
||||
self.trust_permanently(cert)
|
||||
return
|
||||
elif r == "9":
|
||||
print(cert.to_txt())
|
||||
|
@ -1,8 +0,0 @@
|
||||
class NoSecureSSLError(Exception):
|
||||
def __init__(self, msg):
|
||||
Exception.__init__(self)
|
||||
self.msg = msg
|
||||
def __str__(self):
|
||||
return self.msg
|
||||
|
||||
# vim: sw=4 et
|
2
setup.py
2
setup.py
@ -103,7 +103,7 @@ setuptools.setup(
|
||||
packages=['osc', 'osc.util'],
|
||||
scripts=['osc-wrapper.py'],
|
||||
data_files=data_files,
|
||||
install_requires=['M2Crypto', 'chardet'],
|
||||
install_requires=['cryptography', 'urllib3'],
|
||||
classifiers=[
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
|
208
tests/common.py
208
tests/common.py
@ -1,29 +1,18 @@
|
||||
import unittest
|
||||
import osc.core
|
||||
import shutil
|
||||
import tempfile
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
try:
|
||||
# Works up to Python 3.8, needed for Python < 3.3 (inc 2.7)
|
||||
from xml.etree import cElementTree as ET
|
||||
except ImportError:
|
||||
# will import a fast implementation from 3.3 onwards, needed
|
||||
# for 3.9+
|
||||
from xml.etree import ElementTree as ET
|
||||
EXPECTED_REQUESTS = []
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from urllib.request import HTTPHandler, addinfourl, build_opener
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
try:
|
||||
#python 2.x
|
||||
from cStringIO import StringIO
|
||||
from urllib2 import HTTPHandler, addinfourl, build_opener
|
||||
from urlparse import urlparse, parse_qs
|
||||
except ImportError:
|
||||
from io import StringIO
|
||||
from urllib.request import HTTPHandler, addinfourl, build_opener
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import urllib3.response
|
||||
|
||||
import osc.core
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
def urlcompare(url, *args):
|
||||
"""compare all components of url except query string - it is converted to
|
||||
@ -94,94 +83,122 @@ class RequestDataMismatch(Exception):
|
||||
def __str__(self):
|
||||
return '%s, %s, %s' % (self.url, self.got, self.exp)
|
||||
|
||||
class MyHTTPHandler(HTTPHandler):
|
||||
def __init__(self, exp_requests, fixtures_dir):
|
||||
HTTPHandler.__init__(self)
|
||||
self.__exp_requests = exp_requests
|
||||
self.__fixtures_dir = fixtures_dir
|
||||
|
||||
def http_open(self, req):
|
||||
r = self.__exp_requests.pop(0)
|
||||
if not urlcompare(req.get_full_url(), r[1]) or req.get_method() != r[0]:
|
||||
raise RequestWrongOrder(req.get_full_url(), r[1], req.get_method(), r[0])
|
||||
if req.get_method() in ('GET', 'DELETE'):
|
||||
return self.__mock_GET(r[1], **r[2])
|
||||
elif req.get_method() in ('PUT', 'POST'):
|
||||
return self.__mock_PUT(req, **r[2])
|
||||
EXPECTED_REQUESTS = []
|
||||
|
||||
def __mock_GET(self, fullurl, **kwargs):
|
||||
return self.__get_response(fullurl, **kwargs)
|
||||
|
||||
def __mock_PUT(self, req, **kwargs):
|
||||
exp = kwargs.get('exp', None)
|
||||
if exp is not None and 'expfile' in kwargs:
|
||||
raise RuntimeError('either specify exp or expfile')
|
||||
elif 'expfile' in kwargs:
|
||||
exp = open(os.path.join(self.__fixtures_dir, kwargs['expfile']), 'rb').read()
|
||||
elif exp is None:
|
||||
raise RuntimeError('exp or expfile required')
|
||||
# HACK: Fix "ValueError: I/O operation on closed file." error in tests on openSUSE Leap 15.2.
|
||||
# The problem seems to appear only in the tests, possibly some interaction with MockHTTPConnectionPool.
|
||||
# Porting 753fbc03 to urllib3 in openSUSE Leap 15.2 would fix the problem.
|
||||
urllib3.response.HTTPResponse.__iter__ = lambda self : iter(self._fp)
|
||||
|
||||
|
||||
class MockHTTPConnectionPool:
|
||||
def __init__(self, host, port=None, **conn_kw):
|
||||
pass
|
||||
|
||||
def urlopen(self, method, url, body=None, headers=None, retries=None, **response_kw):
|
||||
global EXPECTED_REQUESTS
|
||||
request = EXPECTED_REQUESTS.pop(0)
|
||||
|
||||
url = f"http://localhost{url}"
|
||||
|
||||
if not urlcompare(request["url"], url) or request["method"] != method:
|
||||
raise RequestWrongOrder(request["url"], url, request["method"], method)
|
||||
|
||||
if method in ("POST", "PUT"):
|
||||
if 'exp' not in request and 'expfile' in request:
|
||||
with open(request['expfile'], 'rb') as f:
|
||||
exp = f.read()
|
||||
elif 'exp' in request and 'expfile' not in request:
|
||||
exp = request['exp'].encode('utf-8')
|
||||
else:
|
||||
raise RuntimeError('Specify either `exp` or `expfile`')
|
||||
|
||||
body = body or b""
|
||||
if hasattr(body, "read"):
|
||||
# if it is a file-like object, read it
|
||||
body = body.read()
|
||||
if hasattr(body, "encode"):
|
||||
# if it can be encoded to bytes, do it
|
||||
body = body.encode("utf-8")
|
||||
|
||||
if body != exp:
|
||||
# We do not have a notion to explicitly mark xml content. In case
|
||||
# of xml, we do not care about the exact xml representation (for
|
||||
# now). Hence, if both, data and exp, are xml and are "equal",
|
||||
# everything is fine (for now); otherwise, error out
|
||||
# (of course, this is problematic if we want to ensure that XML
|
||||
# documents are bit identical...)
|
||||
if not xml_equal(body, exp):
|
||||
raise RequestDataMismatch(url, repr(body), repr(exp))
|
||||
|
||||
if 'exception' in request:
|
||||
raise request["exception"]
|
||||
|
||||
if 'text' not in request and 'file' in request:
|
||||
with open(request['file'], 'rb') as f:
|
||||
data = f.read()
|
||||
elif 'text' in request and 'file' not in request:
|
||||
data = request['text'].encode('utf-8')
|
||||
else:
|
||||
# for now, assume exp is a str
|
||||
exp = exp.encode('utf-8')
|
||||
# use req.data instead of req.get_data() for python3 compatiblity
|
||||
data = req.data
|
||||
if hasattr(data, 'read'):
|
||||
data = data.read()
|
||||
if data != exp:
|
||||
# We do not have a notion to explicitly mark xml content. In case
|
||||
# of xml, we do not care about the exact xml representation (for
|
||||
# now). Hence, if both, data and exp, are xml and are "equal",
|
||||
# everything is fine (for now); otherwise, error out
|
||||
# (of course, this is problematic if we want to ensure that XML
|
||||
# documents are bit identical...)
|
||||
if not xml_equal(data, exp):
|
||||
raise RequestDataMismatch(req.get_full_url(), repr(data), repr(exp))
|
||||
return self.__get_response(req.get_full_url(), **kwargs)
|
||||
raise RuntimeError('Specify either `text` or `file`')
|
||||
|
||||
def __get_response(self, url, **kwargs):
|
||||
f = None
|
||||
if 'exception' in kwargs:
|
||||
raise kwargs['exception']
|
||||
if 'text' not in kwargs and 'file' in kwargs:
|
||||
f = BytesIO(open(os.path.join(self.__fixtures_dir, kwargs['file']), 'rb').read())
|
||||
elif 'text' in kwargs and 'file' not in kwargs:
|
||||
f = BytesIO(kwargs['text'].encode('utf-8'))
|
||||
else:
|
||||
raise RuntimeError('either specify text or file')
|
||||
resp = addinfourl(f, {}, url)
|
||||
resp.code = kwargs.get('code', 200)
|
||||
resp.msg = ''
|
||||
return resp
|
||||
response = urllib3.response.HTTPResponse(body=data, status=request.get("code", 200))
|
||||
response._fp = io.BytesIO(data)
|
||||
|
||||
def urldecorator(method, fullurl, **kwargs):
|
||||
return response
|
||||
|
||||
|
||||
def urldecorator(method, url, **kwargs):
|
||||
def decorate(test_method):
|
||||
def wrapped_test_method(*args):
|
||||
addExpectedRequest(method, fullurl, **kwargs)
|
||||
test_method(*args)
|
||||
# "rename" method otherwise we cannot specify a TestCaseClass.testName
|
||||
# cmdline arg when using unittest.main()
|
||||
def wrapped_test_method(self):
|
||||
# put all args into a single dictionary
|
||||
kwargs["method"] = method
|
||||
kwargs["url"] = url
|
||||
|
||||
# prepend fixtures dir to `file`
|
||||
if "file" in kwargs:
|
||||
kwargs["file"] = os.path.join(self._get_fixtures_dir(), kwargs["file"])
|
||||
|
||||
# prepend fixtures dir to `expfile`
|
||||
if "expfile" in kwargs:
|
||||
kwargs["expfile"] = os.path.join(self._get_fixtures_dir(), kwargs["expfile"])
|
||||
|
||||
EXPECTED_REQUESTS.append(kwargs)
|
||||
|
||||
test_method(self)
|
||||
|
||||
# mock connection pool, but only just once
|
||||
if not hasattr(test_method, "_MockHTTPConnectionPool"):
|
||||
wrapped_test_method = patch('urllib3.HTTPConnectionPool', MockHTTPConnectionPool)(wrapped_test_method)
|
||||
wrapped_test_method._MockHTTPConnectionPool = True
|
||||
|
||||
wrapped_test_method.__name__ = test_method.__name__
|
||||
return wrapped_test_method
|
||||
return decorate
|
||||
|
||||
def GET(fullurl, **kwargs):
|
||||
return urldecorator('GET', fullurl, **kwargs)
|
||||
|
||||
def PUT(fullurl, **kwargs):
|
||||
return urldecorator('PUT', fullurl, **kwargs)
|
||||
def GET(path, **kwargs):
|
||||
return urldecorator('GET', path, **kwargs)
|
||||
|
||||
def POST(fullurl, **kwargs):
|
||||
return urldecorator('POST', fullurl, **kwargs)
|
||||
|
||||
def DELETE(fullurl, **kwargs):
|
||||
return urldecorator('DELETE', fullurl, **kwargs)
|
||||
def PUT(path, **kwargs):
|
||||
return urldecorator('PUT', path, **kwargs)
|
||||
|
||||
|
||||
def POST(path, **kwargs):
|
||||
return urldecorator('POST', path, **kwargs)
|
||||
|
||||
|
||||
def DELETE(path, **kwargs):
|
||||
return urldecorator('DELETE', path, **kwargs)
|
||||
|
||||
def addExpectedRequest(method, url, **kwargs):
|
||||
global EXPECTED_REQUESTS
|
||||
EXPECTED_REQUESTS.append((method, url, kwargs))
|
||||
|
||||
class OscTestCase(unittest.TestCase):
|
||||
def setUp(self, copytree=True):
|
||||
global EXPECTED_REQUESTS
|
||||
EXPECTED_REQUESTS = []
|
||||
os.chdir(os.path.dirname(__file__))
|
||||
oscrc = os.path.join(self._get_fixtures_dir(), 'oscrc')
|
||||
osc.core.conf.get_config(override_conffile=oscrc,
|
||||
@ -191,19 +208,16 @@ class OscTestCase(unittest.TestCase):
|
||||
self.tmpdir = tempfile.mkdtemp(prefix='osc_test')
|
||||
if copytree:
|
||||
shutil.copytree(os.path.join(self._get_fixtures_dir(), 'osctest'), os.path.join(self.tmpdir, 'osctest'))
|
||||
global EXPECTED_REQUESTS
|
||||
EXPECTED_REQUESTS = []
|
||||
osc.core.conf._build_opener = lambda u: build_opener(MyHTTPHandler(EXPECTED_REQUESTS, self._get_fixtures_dir()))
|
||||
self.stdout = sys.stdout
|
||||
sys.stdout = StringIO()
|
||||
sys.stdout = io.StringIO()
|
||||
|
||||
def tearDown(self):
|
||||
self.assertTrue(len(EXPECTED_REQUESTS) == 0)
|
||||
sys.stdout = self.stdout
|
||||
try:
|
||||
shutil.rmtree(self.tmpdir)
|
||||
except:
|
||||
pass
|
||||
self.assertTrue(len(EXPECTED_REQUESTS) == 0)
|
||||
|
||||
def _get_fixtures_dir(self):
|
||||
raise NotImplementedError('subclasses should implement this method')
|
||||
|
@ -4,48 +4,41 @@ import osc.oscerr
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from .common import GET, POST, OscTestCase, addExpectedRequest, EXPECTED_REQUESTS
|
||||
from .common import GET, POST, OscTestCase, EXPECTED_REQUESTS
|
||||
|
||||
|
||||
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'prdiff_fixtures')
|
||||
API_URL = 'http://localhost/'
|
||||
UPSTREAM = 'some:project'
|
||||
BRANCH = 'home:user:branches:' + UPSTREAM
|
||||
|
||||
def rdiff_url(pkg, oldprj, newprj):
|
||||
return API_URL + 'source/%s/%s?unified=1&opackage=%s&oproject=%s&cmd=diff&expand=1&tarlimit=0&filelimit=0' % \
|
||||
return 'http://localhost/source/%s/%s?unified=1&opackage=%s&oproject=%s&cmd=diff&expand=1&tarlimit=0&filelimit=0' % \
|
||||
(newprj, pkg, pkg, oldprj.replace(':', '%3A'))
|
||||
|
||||
def request_url(prj):
|
||||
return API_URL + 'search/request?match=%%28state%%2F%%40name%%3D%%27new%%27+or+state%%2F%%40name%%3D%%27review%%27%%29+and+%%28action%%2Ftarget%%2F%%40project%%3D%%27%s%%27+or+action%%2Fsource%%2F%%40project%%3D%%27%s%%27%%29' % \
|
||||
return 'http://localhost/search/request?match=%%28state%%2F%%40name%%3D%%27new%%27+or+state%%2F%%40name%%3D%%27review%%27%%29+and+%%28action%%2Ftarget%%2F%%40project%%3D%%27%s%%27+or+action%%2Fsource%%2F%%40project%%3D%%27%s%%27%%29' % \
|
||||
tuple([prj.replace(':', '%3A')] * 2)
|
||||
|
||||
|
||||
def GET_PROJECT_PACKAGES(*projects):
|
||||
def decorator(test_method):
|
||||
def wrapped_test_method(*args):
|
||||
for project in projects:
|
||||
addExpectedRequest('GET', API_URL + 'source/' + project,
|
||||
file='%s/directory' % project)
|
||||
test_method(*args)
|
||||
# "rename" method otherwise we cannot specify a TestCaseClass.testName
|
||||
# cmdline arg when using unittest.main()
|
||||
wrapped_test_method.__name__ = test_method.__name__
|
||||
return wrapped_test_method
|
||||
# decorators get applied in the reversed order (bottom-up)
|
||||
for project in reversed(projects):
|
||||
test_method = GET(f'http://localhost/source/{project}', file=f'{project}/directory')(test_method)
|
||||
return test_method
|
||||
return decorator
|
||||
|
||||
|
||||
def POST_RDIFF(oldprj, newprj):
|
||||
def decorator(test_method):
|
||||
def wrapped_test_method(*args):
|
||||
addExpectedRequest('POST', rdiff_url('common-one', oldprj, newprj), exp='', text='')
|
||||
addExpectedRequest('POST', rdiff_url('common-two', oldprj, newprj), exp='', file='common-two-diff')
|
||||
addExpectedRequest('POST', rdiff_url('common-three', oldprj, newprj), exp='', text='')
|
||||
test_method(*args)
|
||||
# "rename" method otherwise we cannot specify a TestCaseClass.testName
|
||||
# cmdline arg when using unittest.main()
|
||||
wrapped_test_method.__name__ = test_method.__name__
|
||||
return wrapped_test_method
|
||||
# decorators get applied in the reversed order (bottom-up)
|
||||
test_method = POST(rdiff_url('common-three', oldprj, newprj), exp='', text='')(test_method)
|
||||
test_method = POST(rdiff_url('common-two', oldprj, newprj), exp='', file='common-two-diff')(test_method)
|
||||
test_method = POST(rdiff_url('common-one', oldprj, newprj), exp='', text='')(test_method)
|
||||
return test_method
|
||||
return decorator
|
||||
|
||||
|
||||
def suite():
|
||||
import unittest
|
||||
return unittest.makeSuite(TestProjectDiff)
|
||||
|
Loading…
Reference in New Issue
Block a user