summaryrefslogtreecommitdiff
path: root/lib/spack/llnl/util/symlink.py
blob: 229f3f8115fc52f7f5f320811f206c2cdf545c02 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# Copyright 2013-2024 Lawrence Livermore National Security, LLC and other
# Spack Project Developers. See the top-level COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import os
import re
import shutil
import subprocess
import sys
import tempfile

from llnl.util import lang, tty

from ..path import system_path_filter

if sys.platform == "win32":
    from win32file import CreateHardLink

is_windows = sys.platform == "win32"


def symlink(source_path: str, link_path: str, allow_broken_symlinks: bool = not is_windows):
    """
    Create a link.

    On non-Windows and Windows with System Administrator
    privleges this will be a normal symbolic link via
    os.symlink.

    On Windows without privledges the link will be a
    junction for a directory and a hardlink for a file.
    On Windows the various link types are:

    Symbolic Link: A link to a file or directory on the
    same or different volume (drive letter) or even to
    a remote file or directory (using UNC in its path).
    Need System Administrator privileges to make these.

    Hard Link: A link to a file on the same volume (drive
    letter) only. Every file (file's data) has at least 1
    hard link (file's name). But when this method creates
    a new hard link there will be 2. Deleting all hard
    links effectively deletes the file. Don't need System
    Administrator privileges.

    Junction: A link to a directory on the same or different
    volume (drive letter) but not to a remote directory. Don't
    need System Administrator privileges.

    Parameters:
        source_path (str): The real file or directory that the link points to.
            Must be absolute OR relative to the link.
        link_path (str): The path where the link will exist.
        allow_broken_symlinks (bool): On Linux or Mac, don't raise an exception if the source_path
            doesn't exist. This will still raise an exception on Windows.
    """
    source_path = os.path.normpath(source_path)
    win_source_path = source_path
    link_path = os.path.normpath(link_path)

    # Never allow broken links on Windows.
    if sys.platform == "win32" and allow_broken_symlinks:
        raise ValueError("allow_broken_symlinks parameter cannot be True on Windows.")

    if not allow_broken_symlinks:
        # Perform basic checks to make sure symlinking will succeed
        if os.path.lexists(link_path):
            raise AlreadyExistsError(
                f"Link path ({link_path}) already exists. Cannot create link."
            )

        if not os.path.exists(source_path):
            if os.path.isabs(source_path) and not allow_broken_symlinks:
                # An absolute source path that does not exist will result in a broken link.
                raise SymlinkError(
                    f"Source path ({source_path}) is absolute but does not exist. Resulting "
                    f"link would be broken so not making link."
                )
            else:
                # os.symlink can create a link when the given source path is relative to
                # the link path. Emulate this behavior and check to see if the source exists
                # relative to the link path ahead of link creation to prevent broken
                # links from being made.
                link_parent_dir = os.path.dirname(link_path)
                relative_path = os.path.join(link_parent_dir, source_path)
                if os.path.exists(relative_path):
                    # In order to work on windows, the source path needs to be modified to be
                    # relative because hardlink/junction dont resolve relative paths the same
                    # way as os.symlink. This is ignored on other operating systems.
                    win_source_path = relative_path
                elif not allow_broken_symlinks:
                    raise SymlinkError(
                        f"The source path ({source_path}) is not relative to the link path "
                        f"({link_path}). Resulting link would be broken so not making link."
                    )

    # Create the symlink
    if sys.platform == "win32" and not _windows_can_symlink():
        _windows_create_link(win_source_path, link_path)
    else:
        os.symlink(source_path, link_path, target_is_directory=os.path.isdir(source_path))


def islink(path: str) -> bool:
    """Override os.islink to give correct answer for spack logic.

    For Non-Windows: a link can be determined with the os.path.islink method.
    Windows-only methods will return false for other operating systems.

    For Windows: spack considers symlinks, hard links, and junctions to
    all be links, so if any of those are True, return True.

    Args:
        path (str): path to check if it is a link.

    Returns:
         bool - whether the path is any kind link or not.
    """
    return any([os.path.islink(path), _windows_is_junction(path), _windows_is_hardlink(path)])


def _windows_is_hardlink(path: str) -> bool:
    """Determines if a path is a windows hard link. This is accomplished
    by looking at the number of links using os.stat. A non-hard-linked file
    will have a st_nlink value of 1, whereas a hard link will have a value
    larger than 1. Note that both the original and hard-linked file will
    return True because they share the same inode.

    Args:
        path (str): Windows path to check for a hard link

    Returns:
         bool - Whether the path is a hard link or not.
    """
    if sys.platform != "win32" or os.path.islink(path) or not os.path.exists(path):
        return False

    return os.stat(path).st_nlink > 1


def _windows_is_junction(path: str) -> bool:
    """Determines if a path is a windows junction. A junction can be
    determined using a bitwise AND operation between the file's
    attribute bitmask and the known junction bitmask (0x400).

    Args:
        path (str): A non-file path

    Returns:
        bool - whether the path is a junction or not.
    """
    if sys.platform != "win32" or os.path.islink(path) or os.path.isfile(path):
        return False

    import ctypes.wintypes

    get_file_attributes = ctypes.windll.kernel32.GetFileAttributesW  # type: ignore[attr-defined]
    get_file_attributes.argtypes = (ctypes.wintypes.LPWSTR,)
    get_file_attributes.restype = ctypes.wintypes.DWORD

    invalid_file_attributes = 0xFFFFFFFF
    reparse_point = 0x400
    file_attr = get_file_attributes(str(path))

    if file_attr == invalid_file_attributes:
        return False

    return file_attr & reparse_point > 0


@lang.memoized
def _windows_can_symlink() -> bool:
    """
    Determines if windows is able to make a symlink depending on
    the system configuration and the level of the user's permissions.
    """
    if sys.platform != "win32":
        tty.warn("windows_can_symlink method can't be used on non-Windows OS.")
        return False

    tempdir = tempfile.mkdtemp()

    dpath = os.path.join(tempdir, "dpath")
    fpath = os.path.join(tempdir, "fpath.txt")

    dlink = os.path.join(tempdir, "dlink")
    flink = os.path.join(tempdir, "flink.txt")

    import llnl.util.filesystem as fs

    fs.touchp(fpath)

    try:
        os.symlink(dpath, dlink)
        can_symlink_directories = os.path.islink(dlink)
    except OSError:
        can_symlink_directories = False

    try:
        os.symlink(fpath, flink)
        can_symlink_files = os.path.islink(flink)
    except OSError:
        can_symlink_files = False

    # Cleanup the test directory
    shutil.rmtree(tempdir)

    return can_symlink_directories and can_symlink_files


def _windows_create_link(source: str, link: str):
    """
    Attempts to create a Hard Link or Junction as an alternative
    to a symbolic link. This is called when symbolic links cannot
    be created.
    """
    if sys.platform != "win32":
        raise SymlinkError("windows_create_link method can't be used on non-Windows OS.")
    elif os.path.isdir(source):
        _windows_create_junction(source=source, link=link)
    elif os.path.isfile(source):
        _windows_create_hard_link(path=source, link=link)
    else:
        raise SymlinkError(
            f"Cannot create link from {source}. It is neither a file nor a directory."
        )


def _windows_create_junction(source: str, link: str):
    """Duly verify that the path and link are eligible to create a junction,
    then create the junction.
    """
    if sys.platform != "win32":
        raise SymlinkError("windows_create_junction method can't be used on non-Windows OS.")
    elif not os.path.exists(source):
        raise SymlinkError("Source path does not exist, cannot create a junction.")
    elif os.path.lexists(link):
        raise AlreadyExistsError("Link path already exists, cannot create a junction.")
    elif not os.path.isdir(source):
        raise SymlinkError("Source path is not a directory, cannot create a junction.")

    import subprocess

    cmd = ["cmd", "/C", "mklink", "/J", link, source]
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    out, err = proc.communicate()
    tty.debug(out.decode())
    if proc.returncode != 0:
        err = err.decode()
        tty.error(err)
        raise SymlinkError("Make junction command returned a non-zero return code.", err)


def _windows_create_hard_link(path: str, link: str):
    """Duly verify that the path and link are eligible to create a hard
    link, then create the hard link.
    """
    if sys.platform != "win32":
        raise SymlinkError("windows_create_hard_link method can't be used on non-Windows OS.")
    elif not os.path.exists(path):
        raise SymlinkError(f"File path {path} does not exist. Cannot create hard link.")
    elif os.path.lexists(link):
        raise AlreadyExistsError(f"Link path ({link}) already exists. Cannot create hard link.")
    elif not os.path.isfile(path):
        raise SymlinkError(f"File path ({link}) is not a file. Cannot create hard link.")
    else:
        tty.debug(f"Creating hard link {link} pointing to {path}")
        CreateHardLink(link, path)


def readlink(path: str):
    """Spack utility to override of os.readlink method to work cross platform"""
    if _windows_is_hardlink(path):
        return _windows_read_hard_link(path)
    elif _windows_is_junction(path):
        return _windows_read_junction(path)
    else:
        return os.readlink(path)


def _windows_read_hard_link(link: str) -> str:
    """Find all of the files that point to the same inode as the link"""
    if sys.platform != "win32":
        raise SymlinkError("Can't read hard link on non-Windows OS.")
    link = os.path.abspath(link)
    fsutil_cmd = ["fsutil", "hardlink", "list", link]
    proc = subprocess.Popen(fsutil_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
    out, err = proc.communicate()
    if proc.returncode != 0:
        raise SymlinkError(f"An error occurred while reading hard link: {err.decode()}")

    # fsutil response does not include the drive name, so append it back to each linked file.
    drive, link_tail = os.path.splitdrive(os.path.abspath(link))
    links = set([os.path.join(drive, p) for p in out.decode().splitlines()])
    links.remove(link)
    if len(links) == 1:
        return links.pop()
    elif len(links) > 1:
        # TODO: How best to handle the case where 3 or more paths point to a single inode?
        raise SymlinkError(f"Found multiple paths pointing to the same inode {links}")
    else:
        raise SymlinkError("Cannot determine hard link source path.")


def _windows_read_junction(link: str):
    """Find the path that a junction points to."""
    if sys.platform != "win32":
        raise SymlinkError("Can't read junction on non-Windows OS.")

    link = os.path.abspath(link)
    link_basename = os.path.basename(link)
    link_parent = os.path.dirname(link)
    fsutil_cmd = ["dir", "/a:l", link_parent]
    proc = subprocess.Popen(fsutil_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
    out, err = proc.communicate()
    if proc.returncode != 0:
        raise SymlinkError(f"An error occurred while reading junction: {err.decode()}")
    matches = re.search(rf"<JUNCTION>\s+{link_basename} \[(.*)]", out.decode())
    if matches:
        return matches.group(1)
    else:
        raise SymlinkError("Could not find junction path.")


@system_path_filter
def resolve_link_target_relative_to_the_link(link):
    """
    os.path.isdir uses os.path.exists, which for links will check
    the existence of the link target. If the link target is relative to
    the link, we need to construct a pathname that is valid from
    our cwd (which may not be the same as the link's directory)
    """
    target = readlink(link)
    if os.path.isabs(target):
        return target
    link_dir = os.path.dirname(os.path.abspath(link))
    return os.path.join(link_dir, target)


class SymlinkError(RuntimeError):
    """Exception class for errors raised while creating symlinks,
    junctions and hard links
    """


class AlreadyExistsError(SymlinkError):
    """Link path already exists."""