1# -*- coding: utf-8 -*-
2"""Directory stack and associated utilities for the xonsh shell."""
3import os
4import glob
5import argparse
6import builtins
7import subprocess
8
9from xonsh.lazyasd import lazyobject
10from xonsh.tools import get_sep
11from xonsh.events import events
12from xonsh.platform import ON_WINDOWS
13
14DIRSTACK = []
15"""A list containing the currently remembered directories."""
16_unc_tempDrives = {}
17""" drive: sharePath for temp drive letters we create for UNC mapping"""
18
19
20def _unc_check_enabled() -> bool:
21    """Check whether CMD.EXE is enforcing no-UNC-as-working-directory check.
22
23    Check can be disabled by setting {HKCU, HKLM}/SOFTWARE\Microsoft\Command Processor\DisableUNCCheck:REG_DWORD=1
24
25    Returns:
26        True if `CMD.EXE` is enforcing the check (default Windows situation)
27        False if check is explicitly disabled.
28    """
29    if not ON_WINDOWS:
30        return
31
32    import winreg
33
34    wval = None
35
36    try:
37        key = winreg.OpenKey(
38            winreg.HKEY_CURRENT_USER, r"software\microsoft\command processor"
39        )
40        wval, wtype = winreg.QueryValueEx(key, "DisableUNCCheck")
41        winreg.CloseKey(key)
42    except OSError as e:
43        pass
44
45    if wval is None:
46        try:
47            key2 = winreg.OpenKey(
48                winreg.HKEY_LOCAL_MACHINE, r"software\microsoft\command processor"
49            )
50            wval, wtype = winreg.QueryValueEx(key2, "DisableUNCCheck")
51            winreg.CloseKey(key2)
52        except OSError as e:  # NOQA
53            pass
54
55    return False if wval else True
56
57
58def _is_unc_path(some_path) -> bool:
59    """True if path starts with 2 backward (or forward, due to python path hacking) slashes."""
60    return (
61        len(some_path) > 1
62        and some_path[0] == some_path[1]
63        and some_path[0] in (os.sep, os.altsep)
64    )
65
66
67def _unc_map_temp_drive(unc_path) -> str:
68
69    """Map a new temporary drive letter for each distinct share,
70    unless `CMD.EXE` is not insisting on non-UNC working directory.
71
72    Emulating behavior of `CMD.EXE` `pushd`, create a new mapped drive (starting from Z: towards A:, skipping existing
73     drive letters) for each new UNC path user selects.
74
75    Args:
76        unc_path: the path specified by user.  Assumed to be a UNC path of form \\<server>\share...
77
78    Returns:
79        a replacement for `unc_path` to be used as the actual new working directory.
80        Note that the drive letter may be a the same as one already mapped if the server and share portion of `unc_path`
81         is the same as one still active on the stack.
82    """
83    global _unc_tempDrives
84    assert unc_path[1] in (os.sep, os.altsep), "unc_path is UNC form of path"
85
86    if not _unc_check_enabled():
87        return unc_path
88    else:
89        unc_share, rem_path = os.path.splitdrive(unc_path)
90        unc_share = unc_share.casefold()
91        for d in _unc_tempDrives:
92            if _unc_tempDrives[d] == unc_share:
93                return os.path.join(d, rem_path)
94
95        for dord in range(ord("z"), ord("a"), -1):
96            d = chr(dord) + ":"
97            if not os.path.isdir(d):  # find unused drive letter starting from z:
98                subprocess.check_output(
99                    ["NET", "USE", d, unc_share], universal_newlines=True
100                )
101                _unc_tempDrives[d] = unc_share
102                return os.path.join(d, rem_path)
103
104
105def _unc_unmap_temp_drive(left_drive, cwd):
106    """Unmap a temporary drive letter if it is no longer needed.
107    Called after popping `DIRSTACK` and changing to new working directory, so we need stack *and*
108    new current working directory to be sure drive letter no longer needed.
109
110    Args:
111        left_drive: driveletter (and colon) of working directory we just left
112        cwd: full path of new current working directory
113"""
114
115    global _unc_tempDrives
116
117    if left_drive not in _unc_tempDrives:  # if not one we've mapped, don't unmap it
118        return
119
120    for p in DIRSTACK + [cwd]:  # if still in use , don't unmap it.
121        if p.casefold().startswith(left_drive):
122            return
123
124    _unc_tempDrives.pop(left_drive)
125    subprocess.check_output(
126        ["NET", "USE", left_drive, "/delete"], universal_newlines=True
127    )
128
129
130events.doc(
131    "on_chdir",
132    """
133on_chdir(olddir: str, newdir: str) -> None
134
135Fires when the current directory is changed for any reason.
136""",
137)
138
139
140def _get_cwd():
141    try:
142        return os.getcwd()
143    except (OSError, FileNotFoundError):
144        return None
145
146
147def _change_working_directory(newdir, follow_symlinks=False):
148    env = builtins.__xonsh_env__
149    old = env["PWD"]
150    new = os.path.join(old, newdir)
151    absnew = os.path.abspath(new)
152
153    if follow_symlinks:
154        absnew = os.path.realpath(absnew)
155
156    try:
157        os.chdir(absnew)
158    except (OSError, FileNotFoundError):
159        if new.endswith(get_sep()):
160            new = new[:-1]
161        if os.path.basename(new) == "..":
162            env["PWD"] = new
163    else:
164        if old is not None:
165            env["OLDPWD"] = old
166        if new is not None:
167            env["PWD"] = absnew
168
169    # Fire event if the path actually changed
170    if old != env["PWD"]:
171        events.on_chdir.fire(olddir=old, newdir=env["PWD"])
172
173
174def _try_cdpath(apath):
175    # NOTE: this CDPATH implementation differs from the bash one.
176    # In bash if a CDPATH is set, an unqualified local folder
177    # is considered after all CDPATHs, example:
178    # CDPATH=$HOME/src (with src/xonsh/ inside)
179    # $ cd xonsh -> src/xonsh (with xonsh/xonsh)
180    # a second $ cd xonsh has no effects, to move in the nested xonsh
181    # in bash a full $ cd ./xonsh is needed.
182    # In xonsh a relative folder is always preferred.
183    env = builtins.__xonsh_env__
184    cdpaths = env.get("CDPATH")
185    for cdp in cdpaths:
186        globber = builtins.__xonsh_expand_path__(os.path.join(cdp, apath))
187        for cdpath_prefixed_path in glob.iglob(globber):
188            return cdpath_prefixed_path
189    return apath
190
191
192def cd(args, stdin=None):
193    """Changes the directory.
194
195    If no directory is specified (i.e. if `args` is None) then this
196    changes to the current user's home directory.
197    """
198    env = builtins.__xonsh_env__
199    oldpwd = env.get("OLDPWD", None)
200    cwd = env["PWD"]
201
202    follow_symlinks = False
203    if len(args) > 0 and args[0] == "-P":
204        follow_symlinks = True
205        del args[0]
206
207    if len(args) == 0:
208        d = os.path.expanduser("~")
209    elif len(args) == 1:
210        d = os.path.expanduser(args[0])
211        if not os.path.isdir(d):
212            if d == "-":
213                if oldpwd is not None:
214                    d = oldpwd
215                else:
216                    return "", "cd: no previous directory stored\n", 1
217            elif d.startswith("-"):
218                try:
219                    num = int(d[1:])
220                except ValueError:
221                    return "", "cd: Invalid destination: {0}\n".format(d), 1
222                if num == 0:
223                    return None, None, 0
224                elif num < 0:
225                    return "", "cd: Invalid destination: {0}\n".format(d), 1
226                elif num > len(DIRSTACK):
227                    e = "cd: Too few elements in dirstack ({0} elements)\n"
228                    return "", e.format(len(DIRSTACK)), 1
229                else:
230                    d = DIRSTACK[num - 1]
231            else:
232                d = _try_cdpath(d)
233    else:
234        return (
235            "",
236            (
237                "cd takes 0 or 1 arguments, not {0}. An additional `-P` "
238                "flag can be passed in first position to follow symlinks."
239                "\n".format(len(args))
240            ),
241            1,
242        )
243    if not os.path.exists(d):
244        return "", "cd: no such file or directory: {0}\n".format(d), 1
245    if not os.path.isdir(d):
246        return "", "cd: {0} is not a directory\n".format(d), 1
247    if not os.access(d, os.X_OK):
248        return "", "cd: permission denied: {0}\n".format(d), 1
249    if (
250        ON_WINDOWS
251        and _is_unc_path(d)
252        and _unc_check_enabled()
253        and (not env.get("AUTO_PUSHD"))
254    ):
255        return (
256            "",
257            "cd: can't cd to UNC path on Windows, unless $AUTO_PUSHD set or reg entry "
258            + r"HKCU\SOFTWARE\MICROSOFT\Command Processor\DisableUNCCheck:DWORD = 1"
259            + "\n",
260            1,
261        )
262
263    # now, push the directory onto the dirstack if AUTO_PUSHD is set
264    if cwd is not None and env.get("AUTO_PUSHD"):
265        pushd(["-n", "-q", cwd])
266        if ON_WINDOWS and _is_unc_path(d):
267            d = _unc_map_temp_drive(d)
268    _change_working_directory(d, follow_symlinks)
269    return None, None, 0
270
271
272@lazyobject
273def pushd_parser():
274    parser = argparse.ArgumentParser(prog="pushd")
275    parser.add_argument("dir", nargs="?")
276    parser.add_argument(
277        "-n",
278        dest="cd",
279        help="Suppresses the normal change of directory when"
280        " adding directories to the stack, so that only the"
281        " stack is manipulated.",
282        action="store_false",
283    )
284    parser.add_argument(
285        "-q",
286        dest="quiet",
287        help="Do not call dirs, regardless of $PUSHD_SILENT",
288        action="store_true",
289    )
290    return parser
291
292
293def pushd(args, stdin=None):
294    """xonsh command: pushd
295
296    Adds a directory to the top of the directory stack, or rotates the stack,
297    making the new top of the stack the current working directory.
298
299    On Windows, if the path is a UNC path (begins with `\\<server>\<share>`) and if the `DisableUNCCheck` registry
300    value is not enabled, creates a temporary mapped drive letter and sets the working directory there, emulating
301    behavior of `PUSHD` in `CMD.EXE`
302    """
303    global DIRSTACK
304
305    try:
306        args = pushd_parser.parse_args(args)
307    except SystemExit:
308        return None, None, 1
309
310    env = builtins.__xonsh_env__
311
312    pwd = env["PWD"]
313
314    if env.get("PUSHD_MINUS", False):
315        BACKWARD = "-"
316        FORWARD = "+"
317    else:
318        BACKWARD = "+"
319        FORWARD = "-"
320
321    if args.dir is None:
322        try:
323            new_pwd = DIRSTACK.pop(0)
324        except IndexError:
325            e = "pushd: Directory stack is empty\n"
326            return None, e, 1
327    elif os.path.isdir(args.dir):
328        new_pwd = args.dir
329    else:
330        try:
331            num = int(args.dir[1:])
332        except ValueError:
333            e = "Invalid argument to pushd: {0}\n"
334            return None, e.format(args.dir), 1
335
336        if num < 0:
337            e = "Invalid argument to pushd: {0}\n"
338            return None, e.format(args.dir), 1
339
340        if num > len(DIRSTACK):
341            e = "Too few elements in dirstack ({0} elements)\n"
342            return None, e.format(len(DIRSTACK)), 1
343        elif args.dir.startswith(FORWARD):
344            if num == len(DIRSTACK):
345                new_pwd = None
346            else:
347                new_pwd = DIRSTACK.pop(len(DIRSTACK) - 1 - num)
348        elif args.dir.startswith(BACKWARD):
349            if num == 0:
350                new_pwd = None
351            else:
352                new_pwd = DIRSTACK.pop(num - 1)
353        else:
354            e = "Invalid argument to pushd: {0}\n"
355            return None, e.format(args.dir), 1
356    if new_pwd is not None:
357        if ON_WINDOWS and _is_unc_path(new_pwd):
358            new_pwd = _unc_map_temp_drive(new_pwd)
359        if args.cd:
360            DIRSTACK.insert(0, os.path.expanduser(pwd))
361            _change_working_directory(new_pwd)
362        else:
363            DIRSTACK.insert(0, os.path.expanduser(new_pwd))
364
365    maxsize = env.get("DIRSTACK_SIZE")
366    if len(DIRSTACK) > maxsize:
367        DIRSTACK = DIRSTACK[:maxsize]
368
369    if not args.quiet and not env.get("PUSHD_SILENT"):
370        return dirs([], None)
371
372    return None, None, 0
373
374
375@lazyobject
376def popd_parser():
377    parser = argparse.ArgumentParser(prog="popd")
378    parser.add_argument("dir", nargs="?")
379    parser.add_argument(
380        "-n",
381        dest="cd",
382        help="Suppresses the normal change of directory when"
383        " adding directories to the stack, so that only the"
384        " stack is manipulated.",
385        action="store_false",
386    )
387    parser.add_argument(
388        "-q",
389        dest="quiet",
390        help="Do not call dirs, regardless of $PUSHD_SILENT",
391        action="store_true",
392    )
393    return parser
394
395
396def popd(args, stdin=None):
397    """
398    xonsh command: popd
399
400    Removes entries from the directory stack.
401    """
402    global DIRSTACK
403
404    try:
405        args = pushd_parser.parse_args(args)
406    except SystemExit:
407        return None, None, 1
408
409    env = builtins.__xonsh_env__
410
411    if env.get("PUSHD_MINUS"):
412        BACKWARD = "-"
413        FORWARD = "+"
414    else:
415        BACKWARD = "-"
416        FORWARD = "+"
417
418    if args.dir is None:
419        try:
420            new_pwd = DIRSTACK.pop(0)
421        except IndexError:
422            e = "popd: Directory stack is empty\n"
423            return None, e, 1
424    else:
425        try:
426            num = int(args.dir[1:])
427        except ValueError:
428            e = "Invalid argument to popd: {0}\n"
429            return None, e.format(args.dir), 1
430
431        if num < 0:
432            e = "Invalid argument to popd: {0}\n"
433            return None, e.format(args.dir), 1
434
435        if num > len(DIRSTACK):
436            e = "Too few elements in dirstack ({0} elements)\n"
437            return None, e.format(len(DIRSTACK)), 1
438        elif args.dir.startswith(FORWARD):
439            if num == len(DIRSTACK):
440                new_pwd = DIRSTACK.pop(0)
441            else:
442                new_pwd = None
443                DIRSTACK.pop(len(DIRSTACK) - 1 - num)
444        elif args.dir.startswith(BACKWARD):
445            if num == 0:
446                new_pwd = DIRSTACK.pop(0)
447            else:
448                new_pwd = None
449                DIRSTACK.pop(num - 1)
450        else:
451            e = "Invalid argument to popd: {0}\n"
452            return None, e.format(args.dir), 1
453
454    if new_pwd is not None:
455        e = None
456        if args.cd:
457            env = builtins.__xonsh_env__
458            pwd = env["PWD"]
459
460            _change_working_directory(new_pwd)
461
462            if ON_WINDOWS:
463                drive, rem_path = os.path.splitdrive(pwd)
464                _unc_unmap_temp_drive(drive.casefold(), new_pwd)
465
466    if not args.quiet and not env.get("PUSHD_SILENT"):
467        return dirs([], None)
468
469    return None, None, 0
470
471
472@lazyobject
473def dirs_parser():
474    parser = argparse.ArgumentParser(prog="dirs")
475    parser.add_argument("N", nargs="?")
476    parser.add_argument(
477        "-c",
478        dest="clear",
479        help="Clears the directory stack by deleting all of" " the entries.",
480        action="store_true",
481    )
482    parser.add_argument(
483        "-p",
484        dest="print_long",
485        help="Print the directory stack with one entry per" " line.",
486        action="store_true",
487    )
488    parser.add_argument(
489        "-v",
490        dest="verbose",
491        help="Print the directory stack with one entry per"
492        " line, prefixing each entry with its index in the"
493        " stack.",
494        action="store_true",
495    )
496    parser.add_argument(
497        "-l",
498        dest="long",
499        help="Produces a longer listing; the default listing"
500        " format uses a tilde to denote the home directory.",
501        action="store_true",
502    )
503    return parser
504
505
506def dirs(args, stdin=None):
507    """xonsh command: dirs
508
509    Displays the list of currently remembered directories.  Can also be used
510    to clear the directory stack.
511    """
512    global DIRSTACK
513    try:
514        args = dirs_parser.parse_args(args)
515    except SystemExit:
516        return None, None
517
518    env = builtins.__xonsh_env__
519    dirstack = [os.path.expanduser(env["PWD"])] + DIRSTACK
520
521    if env.get("PUSHD_MINUS"):
522        BACKWARD = "-"
523        FORWARD = "+"
524    else:
525        BACKWARD = "-"
526        FORWARD = "+"
527
528    if args.clear:
529        DIRSTACK = []
530        return None, None, 0
531
532    if args.long:
533        o = dirstack
534    else:
535        d = os.path.expanduser("~")
536        o = [i.replace(d, "~") for i in dirstack]
537
538    if args.verbose:
539        out = ""
540        pad = len(str(len(o) - 1))
541        for (ix, e) in enumerate(o):
542            blanks = " " * (pad - len(str(ix)))
543            out += "\n{0}{1} {2}".format(blanks, ix, e)
544        out = out[1:]
545    elif args.print_long:
546        out = "\n".join(o)
547    else:
548        out = " ".join(o)
549
550    N = args.N
551    if N is not None:
552        try:
553            num = int(N[1:])
554        except ValueError:
555            e = "Invalid argument to dirs: {0}\n"
556            return None, e.format(N), 1
557
558        if num < 0:
559            e = "Invalid argument to dirs: {0}\n"
560            return None, e.format(len(o)), 1
561
562        if num >= len(o):
563            e = "Too few elements in dirstack ({0} elements)\n"
564            return None, e.format(len(o)), 1
565
566        if N.startswith(BACKWARD):
567            idx = num
568        elif N.startswith(FORWARD):
569            idx = len(o) - 1 - num
570        else:
571            e = "Invalid argument to dirs: {0}\n"
572            return None, e.format(N), 1
573
574        out = o[idx]
575
576    return out + "\n", None, 0
577