I first tried pax, however apparently, it does not correctly resolve name collisions beside it's -k option, see also here. Further, FAT's case insensitivity is a problem:
$ pax -k -rw -s '/[?<>\\:*|\"]/_/gp' foo.txt foo.TXT /media/tycho/exfat/
$ ls /media/tycho/exfat/
foo.txt
Oops. foo.TXT is gone without warning.
Next I tried the fuse filesystem posixovl.
$ mount.posixovl -S /media/tycho/exfat/ mpoint
$ rsync -a foo.txt foo.TXT mpoint/
$ ls mpoint/
foo.TXT
Oops. foo.txt is gone without warning.
So I quickly came up with this script, which solves name conflicts by appending a number before the file suffix:
$ exfat-copy foo.txt foo.TXT 'bar?.txt' 'bar<.txt' /media/tycho/exfat
NAMECONFLICT_SOLVED: /media/tycho/exfat/foo.TXT >> /media/tycho/exfat/foo1.TXT
REPLACED: bar?.txt >> bar_.txt
REPLACED: bar<.txt >> bar_.txt
NAMECONFLICT_SOLVED: /media/tycho/exfat/bar_.txt >> /media/tycho/exfat/bar_1.txt
$ ls /media/tycho/exfat/
bar_1.txt bar_.txt foo1.TXT foo.txt
Use at your own risk and don't quote me on it.
#!/usr/bin/env python3
import os
import re
import shutil
import sys
from collections import OrderedDict
def eprint(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
# pax -k -rw -s '/[?<>\\:*|\"]/_/gp'
def replace_chars(s):
# Test with:
# re.sub(r'[?<>\\:*|\"]', "_", "1?2<3:")
new_s = re.sub(r'[?<>\\:*|\"]', "_", s)
if new_s != s:
print('REPLACED:', s, '>>', new_s)
return new_s
def find_new_name(filename):
# already exists
path, name = os.path.split(filename)
new_p = "UNSET"
name, suffix = os.path.splitext(name)
for i in range(1, 10):
new_p = '{}/{}{}{}'.format(path, name, i, suffix)
if not os.path.exists(new_p):
print('NAMECONFLICT_SOLVED:', filename, '>>', new_p)
return new_p
raise ValueError('Failed to find non-conflicting path for {}: last tried {}'.format(filename, new_p))
# based on shutil.copytree
def copytree(src, dst, ignore=None):
names = os.listdir(src)
if ignore is not None:
ignored_names = ignore(src, names)
else:
ignored_names = set()
# conflict due to FAT-based renameing
if os.path.exists(dst):
dst = find_new_name(dst)
os.mkdir(dst)
errors = []
for name in names:
if name in ignored_names:
continue
name_cleaned = replace_chars(name)
srcname = os.path.join(src, name)
dstname = os.path.join(dst, name_cleaned)
handle_filenames(srcname, dstname)
try:
shutil.copystat(src, dst)
except OSError as e:
print('ERROR: shutil.copystat failed on', src, 'and', dst, e)
def handle_filenames(srcname, dstname):
try:
if os.path.islink(srcname):
print('SYMLINK_IGNORED:', srcname)
elif os.path.isdir(srcname):
copytree(srcname, dstname, None)
else:
if os.path.exists(dstname):
dstname = find_new_name(dstname)
# Will raise a SpecialFileError for unsupported file types
shutil.copy2(srcname, dstname)
except OSError as e:
print('ERROR: during handling of', srcname, 'and', dstname, e)
if len(sys.argv) < 3:
eprint("At least two args required: src dst")
exit(1)
src_list = sys.argv[1:-1]
dst = sys.argv[-1]
if not os.path.exists(dst):
eprint(dst, 'must exist')
exit(1)
if len(os.listdir(dst)) != 0:
eprint('destination directory must be empty:', dst)
exit(1)
new_dst = replace_chars(dst)
if new_dst != dst:
eprint("destination directory must only contain valid chars")
exit(1)
src_list = list(OrderedDict.fromkeys(src_list))
if dst in src_list:
eprint('src and dst must be different')
exit(1)
for src in src_list:
dst_sub = replace_chars(os.path.basename(src))
# we allow files and dirs, so cannot start with copytree
handle_filenames(src, dst+'/'+dst_sub)