diff --git a/pycryptomator/__init__.py b/pycryptomator/__init__.py index d1a9212..c0e58bb 100644 --- a/pycryptomator/__init__.py +++ b/pycryptomator/__init__.py @@ -1,3 +1,3 @@ COPYRIGHT = '''Copyright (C)2024, by maxpat78.''' -__version__ = '1.0' +__version__ = '1.1' __all__ = ["Vault", "init_vault", "backupDirIds"] diff --git a/pycryptomator/cmshell.py b/pycryptomator/cmshell.py index 89af56c..4ee8b28 100644 --- a/pycryptomator/cmshell.py +++ b/pycryptomator/cmshell.py @@ -1,7 +1,12 @@ -import cmd, sys, shlex +import cmd, sys, os from os.path import * from .cryptomator import * +if os.name == 'nt': + from w32lex import split # shlex ban \ in pathnames! +else: + from shlex import split + class CMShell(cmd.Cmd): intro = 'PyCryptomator Shell. Type help or ? to list all available commands.' prompt = 'PCM:> ' @@ -22,7 +27,8 @@ def do_quit(p, arg): sys.exit(0) def do_alias(p, arg): - argl = shlex.split(arg) + 'Show the real pathname of a virtual file or directory' + argl = split(arg) if not argl: print('use: alias ') return @@ -31,7 +37,7 @@ def do_alias(p, arg): def do_backup(p, arg): 'Backup all the dir.c9r with their tree structure in a ZIP archive' - argl = shlex.split(arg) + argl = split(arg) if not argl: print('use: backup ') return @@ -39,7 +45,7 @@ def do_backup(p, arg): def do_decrypt(p, arg): 'Decrypt files or directories from the vault' - argl = shlex.split(arg) + argl = split(arg) force = '-f' in argl if force: argl.remove('-f') if not argl or argl[0] == '-h' or len(argl) != 2: @@ -57,7 +63,7 @@ def do_decrypt(p, arg): def do_encrypt(p, arg): 'Encrypt files or directories into the vault' - argl = shlex.split(arg) + argl = split(arg) if not argl or argl[0] == '-h' or len(argl) != 2: print('use: encrypt ') return @@ -71,7 +77,7 @@ def do_encrypt(p, arg): def do_ls(p, arg): 'List files and directories' - argl = shlex.split(arg) + argl = split(arg) recursive = '-r' in argl if recursive: argl.remove('-r') if not argl: argl += ['/'] # implicit argument @@ -86,7 +92,7 @@ def do_ls(p, arg): def do_ln(p, arg): 'Make a symbolic link to a file or directory' - argl = shlex.split(arg) + argl = split(arg) if len(argl) != 2: print('use: ln ') return @@ -97,7 +103,7 @@ def do_ln(p, arg): def do_mkdir(p, arg): 'Make a directory or directory tree' - argl = shlex.split(arg) + argl = split(arg) if not argl or argl[0] == '-h': print('use: mkdir [...]') return @@ -109,7 +115,7 @@ def do_mkdir(p, arg): def do_mv(p, arg): 'Move or rename files or directories' - argl = shlex.split(arg) + argl = split(arg) if len(argl) < 2 or argl[0] == '-h': print('please use: mv [...] ') return @@ -118,7 +124,7 @@ def do_mv(p, arg): def do_rm(p, arg): 'Remove files and directories' - argl = shlex.split(arg) + argl = split(arg) force = '-f' in argl if force: argl.remove('-f') if not argl or argl[0] == '-h': diff --git a/pycryptomator/w32lex/__init__.py b/pycryptomator/w32lex/__init__.py new file mode 100644 index 0000000..cfa7ffe --- /dev/null +++ b/pycryptomator/w32lex/__init__.py @@ -0,0 +1,254 @@ +COPYRIGHT = '''Copyright (C)2024, by maxpat78.''' + +__all__ = ["split", "quote", "join", "cmd_parse", "cmd_split", "cmd_quote"] +__version__ = '0.9.9' + +class NotExpected(Exception): + def __init__ (p, s): + super().__init__(s + ' is not expected') + + +SPLIT_SHELL32 = 0 # CommandLineToArgvW (and pre-2005 VC Runtime) mode (default) +SPLIT_ARGV0 = 1 # full compatibility: simplified parsing of argv[0] +SPLIT_VC2005 = 2 # enable VC2005+ handling of quoted double quote +CMD_VAREXPAND = 4 # expand %variables% + +def split(s, mode=SPLIT_SHELL32): + """Split a command line like CommandLineToArgvW (SHELL32) or old parse_cmdline + (VC Runtime) with mode=SPLIT_SHELL32 (default). With mode=SPLIT_ARGV0, do + special simplified parsing for first argument; with mode=SPLIT_VC2005, emulate + 2005 and newer parse_cmdline.""" + argv = [] # resulting arguments list + arg = '' # current argument + quoted = 0 # if current argument is quoted + backslashes = 0 # backslashes in a row + quotes = 0 # quotes in a row + space = 0 # whitespace in a row + + if not s: return [] + + # Parse 1st argument (executable pathname) in a simplified way, parse_cmdline conformant. + # Argument is everything up to first space if unquoted, or second quote otherwise + if mode&1: + i=0 + for c in s: + i += 1 + if c == '"': + quoted = not quoted + continue + if c in ' \t': + if quoted: + arg += c + continue + break + arg += c + argv += [arg] + arg='' + quoted = 0 + s = s[i:] # strip processed string + + s = s.strip() # strip leading and trailing whitespace + if not s: return argv + + # Special rules: + # Quotes: " open block; "" open and close block; """ open, add literal " and close block + # Backslashes, if followed by ": + # 2n -> n, and open/close block + # (2n+1) -> n, and add literal " + for c in s: + # count backslashes + if c == '\\': + space = 0 # reset count + backslashes += 1 + continue + if c == '"': + space = 0 # reset count + if backslashes: + # take 2n, emit n + arg += '\\' * (backslashes//2) + if backslashes%2: + # if odd, add the escaped literal quote + arg += c + backslashes = 0 + continue + backslashes = 0 + quoted = not quoted + quotes += 1 + # 3" in a row unquoted or 2" quoted -> add a literal " + if quotes == 3 or quotes == 2 and quoted: + arg += c + quoted = not quoted + if mode&2: + quoted = not quoted # new parse_cmdline does NOT change quoting + quotes = 0 + continue + if backslashes: + # simply append the backslashes + arg += '\\' * backslashes + quotes = backslashes = 0 + if c in ' \t': + if quoted: + # append whitespace + arg += c + continue + # ignore whitespace in excess between arguments + if not space: + # append argument + argv += [arg] + arg = '' + space += 1 + continue + space = 0 + # append normal char + arg += c + if backslashes: + arg += '\\' * backslashes + # append last arg + argv += [arg] + return argv + +def quote(s): + "Quote a string in a way suitable for the split function" + backslashes = 0 # backslashes in a row + if not s: return '""' + arg = '' + for c in s: + # count backslashes + if c == '\\': + backslashes += 1 + continue + if c == '"': + if backslashes: + # take n, emit 2n + arg += '\\' * (2*backslashes) + backslashes = 0 + # escape the " + arg += '\\"' + continue + if backslashes: + # add literally + arg += backslashes*'\\' + backslashes = 0 + arg += c + if backslashes: + # double at end, since we quote hereafter + arg += (2*backslashes)*'\\' + arg = '"'+arg+'"' # always quote argument + return arg + +def join(argv): + "Quote and join list items, so that split returns the same" + return ' '.join([quote(arg) for arg in argv]) + +def cmd_parse(s, mode=SPLIT_SHELL32|CMD_VAREXPAND): + "Pre-process a command line like Windows CMD Command Prompt" + escaped = 0 + quoted = 0 + percent = 0 + meta = 0 # special chars in a row + arg = '' + argv = [] + + # remove (ignore) some leading chars + for c in ' ;,=\t\x0B\x0C\xFF': s = s.lstrip(c) + + if not s or s[0] == ':': return [] + + # push special batch char + if s[0] == '@': + argv = ['@'] + s = s[1:] + # some combinations at line start are prohibited + if s[0] in '|&<>': + raise NotExpected(s[0]) + if len(s)>1 and s[0:2] == '()': + raise NotExpected(')') + + i = 0 + while i < len(s): + c = s[i] + i += 1 + if c == '"': + if not escaped: quoted = not quoted + if c == '^': + if escaped or quoted: + arg += c + escaped = 0 + else: + escaped = 1 + continue + # %VAR% -> replace with os.environ['VAR'] *if set* and even if quoted + # ^%VAR% -> same as above + # %VAR^% + # ^%VAR^% -> keep literal %VAR% + # %%VAR%% -> replace internal %VAR% only + if c == '%' and (mode&CMD_VAREXPAND): + arg += c + if percent and percent != i-1: + if not escaped: + vname = s[percent:i-1] + val = os.environ.get(vname) + #~ print('debug: "%s": trying to replace var "%s" with "%s"' %(s,vname,val)) + if val: arg = arg.replace('%'+vname+'%', val) + percent = 0 + continue + percent = i # record percent position + continue + # pipe, redirection, &, && and ||: break argument, and set aside special char/couple + # multiple pipe, redirection, &, && and || in sequence are forbidden + if c in '|<>&': + if escaped or quoted: + arg += c + escaped = 0 + continue + meta += 1 + # 3 specials in a row is forbidden + if meta == 3: raise NotExpected(c) + # if 2 specials undoubled + if len(argv) >= 2 and argv[-1] in '|<>&' and c != argv[-1]: raise NotExpected(c) + # push argument, if any, and special char/couple + if arg: argv += [arg] + argv += [c] + # if doubled operator: ||, <<, >>, && + if i < len(s) and s[i] == c: + argv[-1] = 2*c + i += 1 + meta += 1 + arg = '' + continue + if c in ' ,;=\t': + percent = 0 + # exception (Windows 2000+): starting special char escaped + if i==2 and escaped and c in ',;=': + argv += [c] + escaped = 0 + continue + else: + meta = 0 + arg += c + escaped = 0 + argv += [arg] + return argv + +def cmd_split(s, mode=SPLIT_SHELL32): + "Post-process with split a command line parsed by cmd_parse (mimic mslex behavior)" + argv = [] + for tok in cmd_parse(s, mode): + if tok in ('@','<','|','>','<<','>>','&','&&','||'): + argv += [tok] + continue + argv += split(tok) + return argv + +def cmd_quote(s): + "Quote a string in a way suitable for the cmd_split function" + # suitable means [x] == cmd_split(cmd_quote(x)) + arg = '' + for c in s: + if c in ('^%<|>&'): arg += '^' # escape the escapable! + arg += c + if (' ' in arg) or ('\\' in arg): + # quote only when special split chars inside, + # since quote() always insert into double quotes! + arg = quote(arg) + return arg diff --git a/pyproject.toml b/pyproject.toml index 39c6442..440fc14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "Source" = "https://github.com/maxpat78/pycryptomator" [tool.setuptools] -packages = ["pycryptomator"] +packages = ["pycryptomator", "pycryptomator.w32lex"] package-data = {"pycryptomator" = ["*.txt"]} [tool.setuptools.dynamic]