diff --git a/nipype/utils/filemanip.py b/nipype/utils/filemanip.py index cbf32392eb..59b269e943 100644 --- a/nipype/utils/filemanip.py +++ b/nipype/utils/filemanip.py @@ -12,6 +12,7 @@ import sys import pickle +import subprocess import gzip import hashlib from hashlib import md5 @@ -237,6 +238,54 @@ def hash_timestamp(afile): return md5hex +def _generate_cifs_table(): + """Construct a reverse-length-ordered list of mount points that + fall under a CIFS mount. + + This precomputation allows efficient checking for whether a given path + would be on a CIFS filesystem. + + On systems without a ``mount`` command, or with no CIFS mounts, returns an + empty list. + """ + exit_code, output = subprocess.getstatusoutput("mount") + # Not POSIX + if exit_code != 0: + return [] + + # (path, fstype) tuples, sorted by path length (longest first) + mount_info = sorted((line.split()[2:5:2] for line in output.splitlines()), + key=lambda x: len(x[0]), + reverse=True) + cifs_paths = [path for path, fstype in mount_info if fstype == 'cifs'] + + return [mount for mount in mount_info + if any(mount[0].startswith(path) for path in cifs_paths)] + + +_cifs_table = _generate_cifs_table() + + +def on_cifs(fname): + """ Checks whether a file path is on a CIFS filesystem mounted in a POSIX + host (i.e., has the ``mount`` command). + + On Windows, Docker mounts host directories into containers through CIFS + shares, which has support for Minshall+French symlinks, or text files that + the CIFS driver exposes to the OS as symlinks. + We have found that under concurrent access to the filesystem, this feature + can result in failures to create or read recently-created symlinks, + leading to inconsistent behavior and ``FileNotFoundError``s. + + This check is written to support disabling symlinks on CIFS shares. + """ + # Only the first match (most recent parent) counts + for fspath, fstype in _cifs_table: + if fname.startswith(fspath): + return fstype == 'cifs' + return False + + def copyfile(originalfile, newfile, copy=False, create_new=False, hashmethod=None, use_hardlink=False, copy_related_files=True): @@ -288,6 +337,10 @@ def copyfile(originalfile, newfile, copy=False, create_new=False, if hashmethod is None: hashmethod = config.get('execution', 'hash_method').lower() + # Don't try creating symlinks on CIFS + if copy is False and on_cifs(newfile): + copy = True + # Existing file # ------------- # Options: diff --git a/nipype/utils/tests/test_filemanip.py b/nipype/utils/tests/test_filemanip.py index eba0794e52..9e0f3abb78 100644 --- a/nipype/utils/tests/test_filemanip.py +++ b/nipype/utils/tests/test_filemanip.py @@ -15,6 +15,7 @@ from ...utils.filemanip import (save_json, load_json, fname_presuffix, fnames_presuffix, hash_rename, check_forhash, + _cifs_table, on_cifs, copyfile, copyfiles, filename_to_list, list_to_filename, check_depends, @@ -334,3 +335,28 @@ def test_related_files(file, length, expected_files): for ef in expected_files: assert ef in related_files + +def test_cifs_check(): + assert isinstance(_cifs_table, list) + assert isinstance(on_cifs('/'), bool) + fake_table = [('/scratch/tmp', 'ext4'), ('/scratch', 'cifs')] + cifs_targets = [('/scratch/tmp/x/y', False), + ('/scratch/tmp/x', False), + ('/scratch/x/y', True), + ('/scratch/x', True), + ('/x/y', False), + ('/x', False), + ('/', False)] + + orig_table = _cifs_table[:] + _cifs_table[:] = [] + + for target, _ in cifs_targets: + assert on_cifs(target) is False + + _cifs_table.extend(fake_table) + for target, expected in cifs_targets: + assert on_cifs(target) is expected + + _cifs_table[:] = [] + _cifs_table.extend(orig_table)