File fix-cve-2020-11651-and-fix-cve-2020-11652.patch of Package salt.21019
From d3b899aac9a0fc92f4598384e23f11eccf6a93c8 Mon Sep 17 00:00:00 2001
From: Jochen Breuer <jbreuer@suse.de>
Date: Tue, 28 Apr 2020 11:40:53 +0200
Subject: [PATCH] Fix CVE-2020-11651 and Fix CVE-2020-11652
---
salt/master.py | 58 +++-
salt/tokens/localfs.py | 3 +
salt/utils/verify.py | 57 +++-
salt/wheel/config.py | 8 +-
salt/wheel/file_roots.py | 7 +-
tests/integration/master/test_clear_funcs.py | 310 +++++++++++++++++++
tests/unit/test_master.py | 25 ++
tests/unit/test_module_names.py | 1 +
tests/unit/utils/test_verify.py | 79 +++++
tests/whitelist.txt | 1 +
10 files changed, 533 insertions(+), 16 deletions(-)
create mode 100644 tests/integration/master/test_clear_funcs.py
diff --git a/salt/master.py b/salt/master.py
index 3a9d12999d0cabe784f3069b7c803b5be1a4df19..e42b8385c3935726678d3b726f3b497f7dcef9ab 100644
--- a/salt/master.py
+++ b/salt/master.py
@@ -1089,12 +1089,13 @@ class MWorker(salt.utils.process.SignalHandlingProcess):
'''
log.trace('Clear payload received with command %s', load['cmd'])
cmd = load['cmd']
- if cmd.startswith('__'):
- return False
+ method = self.clear_funcs.get_method(cmd)
+ if not method:
+ return {}, {'fun': 'send_clear'}
if self.opts['master_stats']:
start = time.time()
self.stats[cmd]['runs'] += 1
- ret = getattr(self.clear_funcs, cmd)(load), {'fun': 'send_clear'}
+ ret = method(load), {'fun': 'send_clear'}
if self.opts['master_stats']:
self._post_stats(start, cmd)
return ret
@@ -1112,8 +1113,9 @@ class MWorker(salt.utils.process.SignalHandlingProcess):
return {}
cmd = data['cmd']
log.trace('AES payload received with command %s', data['cmd'])
- if cmd.startswith('__'):
- return False
+ method = self.aes_funcs.get_method(cmd)
+ if not method:
+ return {}, {'fun': 'send'}
if self.opts['master_stats']:
start = time.time()
self.stats[cmd]['runs'] += 1
@@ -1144,13 +1146,44 @@ class MWorker(salt.utils.process.SignalHandlingProcess):
self.__bind()
+class TransportMethods(object):
+ '''
+ Expose methods to the transport layer, methods with their names found in
+ the class attribute 'expose_methods' will be exposed to the transport layer
+ via 'get_method'.
+ '''
+
+ expose_methods = ()
+
+ def get_method(self, name):
+ '''
+ Get a method which should be exposed to the transport layer
+ '''
+ if name in self.expose_methods:
+ try:
+ return getattr(self, name)
+ except AttributeError:
+ log.error("Expose method not found: %s", name)
+ else:
+ log.error("Requested method not exposed: %s", name)
+
+
# TODO: rename? No longer tied to "AES", just "encrypted" or "private" requests
-class AESFuncs(object):
+class AESFuncs(TransportMethods):
'''
Set up functions that are available when the load is encrypted with AES
'''
- # The AES Functions:
- #
+
+ expose_methods = (
+ 'verify_minion', '_master_tops', '_ext_nodes', '_master_opts',
+ '_mine_get', '_mine', '_mine_delete', '_mine_flush', '_file_recv',
+ '_pillar', '_minion_event', '_handle_minion_event', '_return',
+ '_syndic_return', 'minion_runner', 'pub_ret', 'minion_pub',
+ 'minion_publish', 'revoke_auth', 'run_func', '_serve_file',
+ '_file_find', '_file_hash', '_file_find_and_stat', '_file_list',
+ '_file_list_emptydirs', '_dir_list', '_symlink_list', '_file_envs',
+ )
+
def __init__(self, opts):
'''
Create a new AESFuncs
@@ -1864,11 +1897,18 @@ class AESFuncs(object):
return ret, {'fun': 'send'}
-class ClearFuncs(object):
+class ClearFuncs(TransportMethods):
'''
Set up functions that are safe to execute when commands sent to the master
without encryption and authentication
'''
+
+ # These methods will be exposed to the transport layer by
+ # MWorker._handle_clear
+ expose_methods = (
+ 'ping', 'publish', 'get_token', 'mk_token', 'wheel', 'runner',
+ )
+
# The ClearFuncs object encapsulates the functions that can be executed in
# the clear:
# publish (The publish from the LocalClient)
diff --git a/salt/tokens/localfs.py b/salt/tokens/localfs.py
index 3660ee31869239243ba3b1da0338cef6cb18917c..747f8eea1e4515370a1e35d3eb5428568225a683 100644
--- a/salt/tokens/localfs.py
+++ b/salt/tokens/localfs.py
@@ -12,6 +12,7 @@ import logging
import salt.utils.files
import salt.utils.path
+import salt.utils.verify
import salt.payload
from salt.ext import six
@@ -61,6 +62,8 @@ def get_token(opts, tok):
:returns: Token data if successful. Empty dict if failed.
'''
t_path = os.path.join(opts['token_dir'], tok)
+ if not salt.utils.verify.clean_path(opts['token_dir'], t_path):
+ return {}
if not os.path.isfile(t_path):
return {}
serial = salt.payload.Serial(opts)
diff --git a/salt/utils/verify.py b/salt/utils/verify.py
index 57f6bb371fc909d8f706c3f7d708366dc1e3de50..e65d816538deb8e7cf5cb19bbd21880211c007d7 100644
--- a/salt/utils/verify.py
+++ b/salt/utils/verify.py
@@ -31,6 +31,7 @@ import salt.utils.files
import salt.utils.path
import salt.utils.platform
import salt.utils.user
+import salt.ext.six
log = logging.getLogger(__name__)
@@ -495,23 +496,69 @@ def check_max_open_files(opts):
log.log(level=level, msg=msg)
+def _realpath_darwin(path):
+ base = ''
+ for part in path.split(os.path.sep)[1:]:
+ if base != '':
+ if os.path.islink(os.path.sep.join([base, part])):
+ base = os.readlink(os.path.sep.join([base, part]))
+ else:
+ base = os.path.abspath(os.path.sep.join([base, part]))
+ else:
+ base = os.path.abspath(os.path.sep.join([base, part]))
+ return base
+
+
+def _realpath_windows(path):
+ base = ''
+ for part in path.split(os.path.sep):
+ if base != '':
+ try:
+ part = os.readlink(os.path.sep.join([base, part]))
+ base = os.path.abspath(part)
+ except OSError:
+ base = os.path.abspath(os.path.sep.join([base, part]))
+ else:
+ base = part
+ return base
+
+
+def _realpath(path):
+ '''
+ Cross platform realpath method. On Windows when python 3, this method
+ uses the os.readlink method to resolve any filesystem links. On Windows
+ when python 2, this method is a no-op. All other platforms and version use
+ os.path.realpath
+ '''
+ if salt.utils.platform.is_darwin():
+ return _realpath_darwin(path)
+ elif salt.utils.platform.is_windows():
+ if salt.ext.six.PY3:
+ return _realpath_windows(path)
+ else:
+ return path
+ return os.path.realpath(path)
+
+
def clean_path(root, path, subdir=False):
'''
Accepts the root the path needs to be under and verifies that the path is
under said root. Pass in subdir=True if the path can result in a
subdirectory of the root instead of having to reside directly in the root
'''
- if not os.path.isabs(root):
+ real_root = _realpath(root)
+ if not os.path.isabs(real_root):
return ''
if not os.path.isabs(path):
path = os.path.join(root, path)
path = os.path.normpath(path)
+ real_path = _realpath(path)
if subdir:
- if path.startswith(root):
- return path
+ if real_path.startswith(real_root):
+ return real_path
else:
- if os.path.dirname(path) == os.path.normpath(root):
- return path
+ if os.path.dirname(real_path) == os.path.normpath(real_root):
+ return real_path
return ''
diff --git a/salt/wheel/config.py b/salt/wheel/config.py
index a8a93c53e56dd18d384535d13dc97db4c7d23c50..3984444f8f1f966430e8f993f5a93514363868ba 100644
--- a/salt/wheel/config.py
+++ b/salt/wheel/config.py
@@ -75,13 +75,19 @@ def update_config(file_name, yaml_contents):
dir_path = os.path.join(__opts__['config_dir'],
os.path.dirname(__opts__['default_include']))
try:
- yaml_out = salt.utils.yaml.safe_dump(yaml_contents, default_flow_style=False)
+ yaml_out = salt.utils.yaml.safe_dump(
+ yaml_contents,
+ default_flow_style=False,
+ )
if not os.path.exists(dir_path):
log.debug('Creating directory %s', dir_path)
os.makedirs(dir_path, 0o755)
file_path = os.path.join(dir_path, file_name)
+ if not salt.utils.verify.clean_path(dir_path, file_path):
+ return 'Invalid path'
+
with salt.utils.files.fopen(file_path, 'w') as fp_:
fp_.write(yaml_out)
diff --git a/salt/wheel/file_roots.py b/salt/wheel/file_roots.py
index 02cc8c5b32752bac3da40d069a7f0b7c7cdef568..ad42335734e0cb39cf5826cd74e567b85e1b0df0 100644
--- a/salt/wheel/file_roots.py
+++ b/salt/wheel/file_roots.py
@@ -25,6 +25,8 @@ def find(path, saltenv='base'):
return ret
for root in __opts__['file_roots'][saltenv]:
full = os.path.join(root, path)
+ if not salt.utils.verify.clean_path(root, full):
+ continue
if os.path.isfile(full):
# Add it to the dict
with salt.utils.files.fopen(full, 'rb') as fp_:
@@ -107,7 +109,10 @@ def write(data, path, saltenv='base', index=0):
if os.path.isabs(path):
return ('The path passed in {0} is not relative to the environment '
'{1}').format(path, saltenv)
- dest = os.path.join(__opts__['file_roots'][saltenv][index], path)
+ root = __opts__['file_roots'][saltenv][index]
+ dest = os.path.join(root, path)
+ if not salt.utils.verify.clean_path(root, dest, subdir=True):
+ return 'Invalid path: {}'.format(path)
dest_dir = os.path.dirname(dest)
if not os.path.isdir(dest_dir):
os.makedirs(dest_dir)
diff --git a/tests/integration/master/test_clear_funcs.py b/tests/integration/master/test_clear_funcs.py
new file mode 100644
index 0000000000000000000000000000000000000000..4abb257dd96fe462b0de69605a4e0df24cee03a8
--- /dev/null
+++ b/tests/integration/master/test_clear_funcs.py
@@ -0,0 +1,310 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
+import getpass
+import os
+import tempfile
+import time
+
+import salt.master
+import salt.transport.client
+import salt.utils.platform
+import salt.utils.files
+import salt.utils.user
+
+from tests.support.case import TestCase
+from tests.support.mixins import AdaptedConfigurationTestCaseMixin
+from tests.support.runtests import RUNTIME_VARS
+
+
+def keyuser():
+ user = salt.utils.user.get_specific_user()
+ if user.startswith('sudo_'):
+ user = user[5:].replace('\\', '_')
+ return user
+
+
+class ClearFuncsAuthTestCase(TestCase):
+
+ def test_auth_info_not_allowed(self):
+ assert hasattr(salt.master.ClearFuncs, '_prep_auth_info')
+ master = '127.0.0.1'
+ ret_port = 64506
+ user = getpass.getuser()
+ keyfile = '.{}_key'.format(user)
+
+ keypath = os.path.join(RUNTIME_VARS.TMP, 'rootdir', 'cache', keyfile)
+
+ with salt.utils.files.fopen(keypath) as keyfd:
+ key = keyfd.read()
+
+ minion_config = {
+ 'transport': 'zeromq',
+ 'pki_dir': '/tmp',
+ 'id': 'root',
+ 'master_ip': master,
+ 'master_port': ret_port,
+ 'auth_timeout': 5,
+ 'auth_tries': 1,
+ 'master_uri': 'tcp://{0}:{1}'.format(master, ret_port)
+ }
+
+ clear_channel = salt.transport.client.ReqChannel.factory(
+ minion_config, crypt='clear')
+
+ msg = {'cmd': '_prep_auth_info'}
+ rets = clear_channel.send(msg, timeout=15)
+ ret_key = None
+ for ret in rets:
+ try:
+ ret_key = ret[user]
+ break
+ except (TypeError, KeyError):
+ pass
+ assert ret_key != key, 'Able to retrieve user key'
+
+
+class ClearFuncsPubTestCase(TestCase):
+
+ def setUp(self):
+ self.master = '127.0.0.1'
+ self.ret_port = 64506
+ self.tmpfile = os.path.join(tempfile.mkdtemp(), 'evil_file')
+ self.master_opts = AdaptedConfigurationTestCaseMixin.get_config('master')
+
+ def tearDown(self):
+ try:
+ os.remove(self.tmpfile + 'x')
+ except OSError:
+ pass
+ delattr(self, 'master')
+ delattr(self, 'ret_port')
+ delattr(self, 'tmpfile')
+
+ def test_pub_not_allowed(self):
+ assert hasattr(salt.master.ClearFuncs, '_send_pub')
+ assert not os.path.exists(self.tmpfile)
+ minion_config = {
+ 'transport': 'zeromq',
+ 'pki_dir': '/tmp',
+ 'id': 'root',
+ 'master_ip': self.master,
+ 'master_port': self.ret_port,
+ 'auth_timeout': 5,
+ 'auth_tries': 1,
+ 'master_uri': 'tcp://{0}:{1}'.format(self.master, self.ret_port),
+ }
+ clear_channel = salt.transport.client.ReqChannel.factory(
+ minion_config, crypt='clear')
+ jid = '202003100000000001'
+ msg = {
+ 'cmd': '_send_pub',
+ 'fun': 'file.write',
+ 'jid': jid,
+ 'arg': [self.tmpfile, 'evil contents'],
+ 'kwargs': {'show_jid': False, 'show_timeout': False},
+ 'ret': '',
+ 'tgt': 'minion',
+ 'tgt_type': 'glob',
+ 'user': 'root'
+ }
+ eventbus = salt.utils.event.get_event(
+ 'master',
+ sock_dir=self.master_opts['sock_dir'],
+ transport=self.master_opts['transport'],
+ opts=self.master_opts)
+ ret = clear_channel.send(msg, timeout=15)
+ if salt.utils.platform.is_windows():
+ time.sleep(30)
+ timeout = 30
+ else:
+ timeout = 5
+ ret_evt = None
+ start = time.time()
+ while time.time() - start <= timeout:
+ raw = eventbus.get_event(timeout, auto_reconnect=True)
+ if raw and 'jid' in raw and raw['jid'] == jid:
+ ret_evt = raw
+ break
+ assert not os.path.exists(self.tmpfile), 'Evil file created'
+
+
+class ClearFuncsConfigTest(TestCase):
+
+ def setUp(self):
+ master_opts = AdaptedConfigurationTestCaseMixin.get_config('master')
+ self.conf_dir = os.path.dirname(master_opts['conf_file'])
+ master = '127.0.0.1'
+ ret_port = 64506
+ user = keyuser()
+ keyfile = '.{}_key'.format(user)
+ keypath = os.path.join(RUNTIME_VARS.TMP, 'rootdir', 'cache', keyfile)
+
+ with salt.utils.files.fopen(keypath) as keyfd:
+ self.key = keyfd.read()
+
+ self.minion_config = {
+ 'transport': 'zeromq',
+ 'pki_dir': '/tmp',
+ 'id': 'root',
+ 'master_ip': master,
+ 'master_port': ret_port,
+ 'auth_timeout': 5,
+ 'auth_tries': 1,
+ 'master_uri': 'tcp://{0}:{1}'.format(master, ret_port)
+ }
+
+ def tearDown(self):
+ try:
+ os.remove(os.path.join(self.conf_dir, 'evil.conf'))
+ except OSError:
+ pass
+ delattr(self, 'conf_dir')
+ delattr(self, 'key')
+ delattr(self, 'minion_config')
+
+ def test_clearfuncs_config(self):
+ clear_channel = salt.transport.client.ReqChannel.factory(
+ self.minion_config, crypt='clear')
+
+ msg = {
+ 'key': self.key,
+ 'cmd': 'wheel',
+ 'fun': 'config.update_config',
+ 'file_name': '../evil',
+ 'yaml_contents': 'win',
+ }
+ ret = clear_channel.send(msg, timeout=5)
+ assert not os.path.exists(os.path.join(self.conf_dir, 'evil.conf')), \
+ 'Wrote file via directory traversal'
+
+
+class ClearFuncsFileRoots(TestCase):
+
+ def setUp(self):
+ self.master_opts = AdaptedConfigurationTestCaseMixin.get_config('master')
+ self.target_dir = os.path.dirname(
+ self.master_opts['file_roots']['base'][0]
+ )
+ master = '127.0.0.1'
+ ret_port = 64506
+ user = keyuser()
+ self.keyfile = '.{}_key'.format(user)
+ keypath = os.path.join(RUNTIME_VARS.TMP, 'rootdir', 'cache', self.keyfile)
+
+ with salt.utils.files.fopen(keypath) as keyfd:
+ self.key = keyfd.read()
+
+ self.minion_config = {
+ 'transport': 'zeromq',
+ 'pki_dir': '/tmp',
+ 'id': 'root',
+ 'master_ip': master,
+ 'master_port': ret_port,
+ 'auth_timeout': 5,
+ 'auth_tries': 1,
+ 'master_uri': 'tcp://{0}:{1}'.format(master, ret_port)
+ }
+
+ def tearDown(self):
+ try:
+ os.remove(os.path.join(self.target_dir, 'pwn.txt'))
+ except OSError:
+ pass
+ delattr(self, 'master_opts')
+ delattr(self, 'target_dir')
+ delattr(self, 'keyfile')
+ delattr(self, 'key')
+ delattr(self, 'minion_config')
+
+ def test_fileroots_write(self):
+ clear_channel = salt.transport.client.ReqChannel.factory(
+ self.minion_config, crypt='clear')
+
+ msg = {
+ 'key': self.key,
+ 'cmd': 'wheel',
+ 'fun': 'file_roots.write',
+ 'data': 'win',
+ 'path': os.path.join('..', 'pwn.txt'),
+ 'saltenv': 'base',
+ }
+ ret = clear_channel.send(msg, timeout=5)
+ assert not os.path.exists(os.path.join(self.target_dir, 'pwn.txt')), \
+ 'Wrote file via directory traversal'
+
+ def test_fileroots_read(self):
+ rootdir = self.master_opts['root_dir']
+ fileroot = self.master_opts['file_roots']['base'][0]
+ # If this asserion fails the test may need to be re-written
+ assert os.path.dirname(os.path.dirname(rootdir)) == os.path.dirname(fileroot)
+ clear_channel = salt.transport.client.ReqChannel.factory(
+ self.minion_config, crypt='clear')
+ readpath = os.path.join(
+ '..',
+ 'salt-tests-tmpdir',
+ 'rootdir',
+ 'cache',
+ self.keyfile,
+ )
+ msg = {
+ 'key': self.key,
+ 'cmd': 'wheel',
+ 'fun': 'file_roots.read',
+ 'path': os.path.join(
+ '..',
+ 'salt-tests-tmpdir',
+ 'rootdir',
+ 'cache',
+ self.keyfile,
+ ),
+ 'saltenv': 'base',
+ }
+
+ ret = clear_channel.send(msg, timeout=5)
+ try:
+ # When vulnerable this assertion will fail.
+ assert list(ret['data']['return'][0].items())[0][1] != self.key, \
+ 'Read file via directory traversal'
+ except IndexError:
+ pass
+ # If the vulnerability is fixed, no data will be returned.
+ assert ret['data']['return'] == []
+
+
+class ClearFuncsTokenTest(TestCase):
+
+ def setUp(self):
+ self.master_opts = AdaptedConfigurationTestCaseMixin.get_config('master')
+ master = '127.0.0.1'
+ ret_port = 64506
+ self.minion_config = {
+ 'transport': 'zeromq',
+ 'pki_dir': '/tmp',
+ 'id': 'root',
+ 'master_ip': master,
+ 'master_port': ret_port,
+ 'auth_timeout': 5,
+ 'auth_tries': 1,
+ 'master_uri': 'tcp://{0}:{1}'.format(master, ret_port)
+ }
+
+ def tearDown(self):
+ delattr(self, 'master_opts')
+ delattr(self, 'minion_config')
+
+ def test_token(self):
+ tokensdir = os.path.join(
+ self.master_opts['root_dir'],
+ self.master_opts['cachedir'],
+ 'tokens'
+ )
+ assert os.path.exists(tokensdir), tokensdir
+ clear_channel = salt.transport.client.ReqChannel.factory(
+ self.minion_config, crypt='clear')
+ msg = {
+ 'arg': [],
+ 'cmd': 'get_token',
+ 'token': os.path.join('..', 'minions', 'minion', 'data.p'),
+ }
+ ret = clear_channel.send(msg, timeout=5)
+ assert 'pillar' not in ret, 'Read minion data via directory traversal'
diff --git a/tests/unit/test_master.py b/tests/unit/test_master.py
index b7394ffa01d76601d46f99894e8d33f0bb25d114..c730f61594dadcb5743cc8f228fe8c99153c24d7 100644
--- a/tests/unit/test_master.py
+++ b/tests/unit/test_master.py
@@ -15,6 +15,24 @@ from tests.support.mock import (
)
+class TransportMethodsTest(TestCase):
+
+ def test_transport_methods(self):
+
+ class Foo(salt.master.TransportMethods):
+ expose_methods = ['bar']
+
+ def bar(self):
+ pass
+
+ def bang(self):
+ pass
+
+ foo = Foo()
+ assert foo.get_method('bar') is not None
+ assert foo.get_method('bang') is None
+
+
class ClearFuncsTestCase(TestCase):
'''
TestCase for salt.master.ClearFuncs class
@@ -24,6 +42,13 @@ class ClearFuncsTestCase(TestCase):
opts = salt.config.master_config(None)
self.clear_funcs = salt.master.ClearFuncs(opts, {})
+ def tearDown(self):
+ del self.clear_funcs
+
+ def test_get_method(self):
+ assert getattr(self.clear_funcs, '_send_pub', None) is not None
+ assert self.clear_funcs.get_method('_send_pub') is None
+
# runner tests
def test_runner_token_not_authenticated(self):
diff --git a/tests/unit/test_module_names.py b/tests/unit/test_module_names.py
index 8ba2bd85df1bad17202487435e7dd01d00bfa377..7a873d4ec3df22609f47fea490e752c8e8c4b6b0 100644
--- a/tests/unit/test_module_names.py
+++ b/tests/unit/test_module_names.py
@@ -133,6 +133,7 @@ class BadTestModuleNamesTestCase(TestCase):
'integration.logging.test_jid_logging',
'integration.logging.handlers.test_logstash_mod',
'integration.master.test_event_return',
+ 'integration.master.test_clear_funcs',
'integration.minion.test_blackout',
'integration.minion.test_pillar',
'integration.minion.test_executor',
diff --git a/tests/unit/utils/test_verify.py b/tests/unit/utils/test_verify.py
index a90c4192b4bff9d8fc427cd8b3ea515f9b1925de..aa5cf4220dd008eecf36d3eec9304922bb553912 100644
--- a/tests/unit/utils/test_verify.py
+++ b/tests/unit/utils/test_verify.py
@@ -12,6 +12,7 @@ import stat
import shutil
import tempfile
import socket
+import ctypes
# Import third party libs
if sys.platform.startswith('win'):
@@ -45,6 +46,7 @@ from salt.utils.verify import (
verify_log,
verify_logs_filter,
verify_log_files,
+ clean_path
)
# Import 3rd-party libs
@@ -320,3 +322,80 @@ class TestVerifyLog(TestCase):
self.assertFalse(os.path.exists(path))
verify_log_files([path], getpass.getuser())
self.assertTrue(os.path.exists(path))
+
+
+class TestCleanPath(TestCase):
+ '''
+ salt.utils.clean_path works as expected
+ '''
+ def setUp(self):
+ self.tmpdir = tempfile.mkdtemp()
+
+ def tearDown(self):
+ shutil.rmtree(self.tmpdir)
+
+ def test_clean_path_valid(self):
+ path_a = os.path.join(self.tmpdir, 'foo')
+ path_b = os.path.join(self.tmpdir, 'foo', 'bar')
+ assert clean_path(path_a, path_b) == path_b
+
+ def test_clean_path_invalid(self):
+ path_a = os.path.join(self.tmpdir, 'foo')
+ path_b = os.path.join(self.tmpdir, 'baz', 'bar')
+ assert clean_path(path_a, path_b) == ''
+
+
+__CSL = None
+
+
+def symlink(source, link_name):
+ '''
+ symlink(source, link_name) Creates a symbolic link pointing to source named
+ link_name
+ '''
+ global __CSL
+ if __CSL is None:
+ csl = ctypes.windll.kernel32.CreateSymbolicLinkW
+ csl.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32)
+ csl.restype = ctypes.c_ubyte
+ __CSL = csl
+ flags = 0
+ if source is not None and os.path.isdir(source):
+ flags = 1
+ if __CSL(link_name, source, flags) == 0:
+ raise ctypes.WinError()
+
+
+@skipIf(six.PY2 and salt.utils.platform.is_windows(), 'Skipped on windows py2')
+class TestCleanPathLink(TestCase):
+ '''
+ Ensure salt.utils.clean_path works with symlinked directories and files
+ '''
+
+ def setUp(self):
+ self.tmpdir = tempfile.mkdtemp()
+ self.to_path = os.path.join(self.tmpdir, 'linkto')
+ self.from_path = os.path.join(self.tmpdir, 'linkfrom')
+ if six.PY2 or salt.utils.platform.is_windows():
+ kwargs = {}
+ else:
+ kwargs = {'target_is_directory': True}
+ if salt.utils.platform.is_windows():
+ symlink(self.to_path, self.from_path, **kwargs)
+ else:
+ os.symlink(self.to_path, self.from_path, **kwargs)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmpdir)
+
+ def test_clean_path_symlinked_src(self):
+ test_path = os.path.join(self.from_path, 'test')
+ expect_path = os.path.join(self.to_path, 'test')
+ ret = clean_path(self.from_path, test_path)
+ assert ret == expect_path, "{} is not {}".format(ret, expect_path)
+
+ def test_clean_path_symlinked_tgt(self):
+ test_path = os.path.join(self.to_path, 'test')
+ expect_path = os.path.join(self.to_path, 'test')
+ ret = clean_path(self.from_path, test_path)
+ assert ret == expect_path, "{} is not {}".format(ret, expect_path)
diff --git a/tests/whitelist.txt b/tests/whitelist.txt
index 9e63fa5e0aac49b7928cc119047e89914fca5f08..30fddb0e2446011eca987c84fc9c40ba3ca0912f 100644
--- a/tests/whitelist.txt
+++ b/tests/whitelist.txt
@@ -10,6 +10,7 @@ integration.grains.test_core
integration.grains.test_custom
integration.loader.test_ext_grains
integration.loader.test_ext_modules
+integration.master.test_clear_funcs
integration.minion.test_blackout
integration.minion.test_pillar
integration.minion.test_timeout
--
2.23.0