Skip to content

Commit c6ee3eb

Browse files
authored
Rework alias linking to ensure links are only created within bin directory (#277)
Fixes #258
1 parent a11b7d3 commit c6ee3eb

File tree

5 files changed

+131
-78
lines changed

5 files changed

+131
-78
lines changed

src/manage/aliasutils.py

Lines changed: 67 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from .exceptions import FilesInUseError, NoLauncherTemplateError
44
from .fsutils import atomic_unlink, ensure_tree, unlink
55
from .logging import LOGGER
6-
from .pathutils import Path
6+
from .pathutils import Path, relative_to
77
from .tagutils import install_matches_any
88

99
_EXE = ".exe".casefold()
@@ -105,16 +105,19 @@ def _create_alias(
105105
if windowed:
106106
launcher = cmd.launcherw_exe or launcher
107107

108+
chosen_by = "default"
108109
if plat:
109-
LOGGER.debug("Checking for launcher for platform -%s", plat)
110110
launcher = _if_exists(launcher, f"-{plat}")
111+
chosen_by = "platform tag"
111112
if not launcher.is_file():
112-
LOGGER.debug("Checking for launcher for default platform %s", cmd.default_platform)
113113
launcher = _if_exists(launcher, cmd.default_platform)
114+
chosen_by = "default platform"
114115
if not launcher.is_file():
115-
LOGGER.debug("Checking for launcher for -64")
116116
launcher = _if_exists(launcher, "-64")
117-
LOGGER.debug("Create %s linking to %s using %s", name, target, launcher)
117+
chosen_by = "fallback default"
118+
LOGGER.debug("Create %s for %s using %s, chosen by %s", name,
119+
relative_to(target, getattr(cmd, "install_dir", None)),
120+
launcher, chosen_by)
118121
if not launcher or not launcher.is_file():
119122
raise NoLauncherTemplateError()
120123

@@ -128,18 +131,62 @@ def _create_alias(
128131
LOGGER.debug("Failed to read %s", launcher, exc_info=True)
129132
return
130133

134+
force = getattr(cmd, "force", False)
131135
existing_bytes = b''
132-
try:
133-
with open(p, 'rb') as f:
134-
existing_bytes = f.read(len(launcher_bytes) + 1)
135-
except FileNotFoundError:
136-
pass
137-
except OSError:
138-
LOGGER.debug("Failed to read existing alias launcher.")
136+
if force:
137+
# Only expect InstallCommand to have .force
138+
unlink(p)
139+
else:
140+
try:
141+
with open(p, 'rb') as f:
142+
existing_bytes = f.read(len(launcher_bytes) + 1)
143+
except FileNotFoundError:
144+
pass
145+
except OSError:
146+
LOGGER.debug("Failed to read existing alias launcher.")
139147

140148
launcher_remap = cmd.scratch.setdefault("aliasutils.create_alias.launcher_remap", {})
141-
if not allow_link or not _link:
142-
# If links are disallowed, always replace the target with a copy.
149+
150+
if existing_bytes != launcher_bytes and allow_link and _link:
151+
# Try to find an existing launcher we can hard-link
152+
launcher2 = launcher_remap.get(launcher.name)
153+
if (not launcher2 or not launcher2.is_file()) and not force:
154+
# None known, so search existing files. Or, user is forcing us, so
155+
# we only want to use an existing launcher if we've cached it this
156+
# session.
157+
try:
158+
LOGGER.debug("Searching %s for suitable launcher to link", cmd.global_dir)
159+
for p2 in cmd.global_dir.glob("*.exe"):
160+
try:
161+
with open(p2, 'rb') as f:
162+
existing_bytes2 = f.read(len(launcher_bytes) + 1)
163+
except OSError:
164+
LOGGER.debug("Failed to check %s contents", p2, exc_info=True)
165+
else:
166+
if existing_bytes2 == launcher_bytes:
167+
launcher2 = p2
168+
break
169+
else:
170+
LOGGER.debug("No existing launcher available")
171+
except Exception:
172+
LOGGER.debug("Failed to find existing launcher", exc_info=True)
173+
174+
if launcher2 and launcher2.is_file():
175+
# We know that the target either doesn't exist or needs replacing
176+
unlink(p)
177+
try:
178+
_link(launcher2, p)
179+
existing_bytes = launcher_bytes
180+
launcher_remap[launcher.name] = launcher2
181+
LOGGER.debug("Created %s as hard link to %s", p.name, launcher2.name)
182+
except FileNotFoundError:
183+
raise
184+
except OSError:
185+
LOGGER.debug("Failed to create hard link to %s", launcher2.name)
186+
launcher2 = None
187+
188+
# Recheck - existing_bytes will have been updated if we successfully linked
189+
if existing_bytes != launcher_bytes:
143190
unlink(p)
144191
try:
145192
p.write_bytes(launcher_bytes)
@@ -148,43 +195,6 @@ def _create_alias(
148195
except OSError:
149196
LOGGER.error("Failed to create global command %s.", name)
150197
LOGGER.debug("TRACEBACK", exc_info=True)
151-
elif existing_bytes == launcher_bytes:
152-
# Valid existing launcher, so save its path in case we need it later
153-
# for a hard link.
154-
launcher_remap.setdefault(launcher.name, p)
155-
else:
156-
# Links are allowed and we need to create one, so try to make a link,
157-
# falling back to a link to another existing alias (that we've checked
158-
# already during this run), and then falling back to a copy.
159-
# This handles the case where our links are on a different volume to the
160-
# install (so hard links don't work), but limits us to only a single
161-
# copy (each) of the redirector(s), thus saving space.
162-
unlink(p)
163-
try:
164-
_link(launcher, p)
165-
LOGGER.debug("Created %s as hard link to %s", p.name, launcher.name)
166-
except OSError as ex:
167-
if ex.winerror != 17:
168-
# Report errors other than cross-drive links
169-
LOGGER.debug("Failed to create hard link for command.", exc_info=True)
170-
launcher2 = launcher_remap.get(launcher.name)
171-
if launcher2:
172-
try:
173-
_link(launcher2, p)
174-
LOGGER.debug("Created %s as hard link to %s", p.name, launcher2.name)
175-
except FileNotFoundError:
176-
raise
177-
except OSError:
178-
LOGGER.debug("Failed to create hard link to fallback launcher")
179-
launcher2 = None
180-
if not launcher2:
181-
try:
182-
p.write_bytes(launcher_bytes)
183-
LOGGER.debug("Created %s as copy of %s", p.name, launcher.name)
184-
launcher_remap[launcher.name] = p
185-
except OSError:
186-
LOGGER.error("Failed to create global command %s.", name)
187-
LOGGER.debug("TRACEBACK", exc_info=True)
188198

189199
p_target = p.with_name(p.name + ".__target__")
190200
do_update = True
@@ -252,13 +262,14 @@ def _readlines(path):
252262
return
253263

254264

255-
def _scan_one(install, root):
265+
def _scan_one(cmd, install, root):
256266
# Scan d for dist-info directories with entry_points.txt
257267
dist_info = [d for d in root.glob("*.dist-info") if d.is_dir()]
258268
entrypoints = [f for f in [d / "entry_points.txt" for d in dist_info] if f.is_file()]
259269
if len(entrypoints):
260270
LOGGER.debug("Found %i entry_points.txt files in %i dist-info in %s",
261-
len(entrypoints), len(dist_info), root)
271+
len(entrypoints), len(dist_info),
272+
relative_to(root, getattr(cmd, "install_dir", None)))
262273

263274
# Filter down to [console_scripts] and [gui_scripts]
264275
for ep in entrypoints:
@@ -277,10 +288,10 @@ def _scan_one(install, root):
277288
mod=mod, func=func, **alias)
278289

279290

280-
def _scan(install, prefix, dirs):
291+
def _scan(cmd, install, prefix, dirs):
281292
for dirname in dirs or ():
282293
root = prefix / dirname
283-
yield from _scan_one(install, root)
294+
yield from _scan_one(cmd, install, root)
284295

285296

286297
def calculate_aliases(cmd, install, *, _scan=_scan):
@@ -322,7 +333,7 @@ def calculate_aliases(cmd, install, *, _scan=_scan):
322333
site_dirs = s.get("dirs", ())
323334
break
324335

325-
for ai in _scan(install, prefix, site_dirs):
336+
for ai in _scan(cmd, install, prefix, site_dirs):
326337
if ai.windowed and default_alias_w:
327338
yield ai.replace(target=default_alias_w.target)
328339
elif not ai.windowed and default_alias:

src/manage/pathutils.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@
77
import os
88

99

10+
def _eq(x, y):
11+
return x == y or x.casefold() == y.casefold()
12+
13+
1014
class PurePath:
1115
def __init__(self, *parts):
1216
total = ""
1317
for p in parts:
1418
try:
1519
p = p.__fspath__().replace("/", "\\")
1620
except AttributeError:
17-
p = str(p).replace("/", "\\")
21+
p = os.fsdecode(p).replace("/", "\\")
1822
p = p.replace("\\\\", "\\")
1923
if p == ".":
2024
continue
@@ -24,8 +28,11 @@ def __init__(self, *parts):
2428
total += "\\" + p
2529
else:
2630
total += p
27-
self._parent, _, self.name = total.rpartition("\\")
28-
self._p = total.rstrip("\\")
31+
drive, root, tail = os.path.splitroot(total)
32+
parent, _, name = tail.rpartition("\\")
33+
self._parent = drive + root + parent
34+
self.name = name
35+
self._p = drive + root + tail.rstrip("\\")
2936

3037
def __fspath__(self):
3138
return self._p
@@ -36,6 +43,9 @@ def __repr__(self):
3643
def __str__(self):
3744
return self._p
3845

46+
def __bytes__(self):
47+
return os.fsencode(self)
48+
3949
def __hash__(self):
4050
return hash(self._p.casefold())
4151

@@ -86,13 +96,13 @@ def __truediv__(self, other):
8696

8797
def __eq__(self, other):
8898
if isinstance(other, PurePath):
89-
return self._p.casefold() == other._p.casefold()
90-
return self._p.casefold() == str(other).casefold()
99+
return _eq(self._p, other._p)
100+
return _eq(self._p, str(other))
91101

92102
def __ne__(self, other):
93103
if isinstance(other, PurePath):
94-
return self._p.casefold() != other._p.casefold()
95-
return self._p.casefold() != str(other).casefold()
104+
return not _eq(self._p, other._p)
105+
return not _eq(self._p, str(other))
96106

97107
def with_name(self, name):
98108
return type(self)(os.path.join(self._parent, name))
@@ -105,7 +115,7 @@ def with_suffix(self, suffix):
105115
def relative_to(self, base):
106116
base = PurePath(base).parts
107117
parts = self.parts
108-
if not all(x.casefold() == y.casefold() for x, y in zip(base, parts)):
118+
if not all(_eq(x, y) for x, y in zip(base, parts)):
109119
raise ValueError("path not relative to base")
110120
return type(self)("\\".join(parts[len(base):]))
111121

@@ -128,7 +138,7 @@ def match(self, pattern, full_match=False):
128138
m = m.casefold()
129139

130140
if "*" not in p:
131-
return m.casefold() == p
141+
return m == p or m.casefold() == p
132142

133143
must_start_with = True
134144
for bit in p.split("*"):
@@ -219,3 +229,18 @@ def write_bytes(self, data):
219229
def write_text(self, text, encoding="utf-8", errors="strict"):
220230
with open(self._p, "w", encoding=encoding, errors=errors) as f:
221231
f.write(text)
232+
233+
234+
def relative_to(path, root):
235+
if not root:
236+
return path
237+
parts_1 = list(PurePath(path).parts)
238+
parts_2 = list(PurePath(root).parts)
239+
while parts_1 and parts_2 and _eq(parts_1[0], parts_2[0]):
240+
parts_1.pop(0)
241+
parts_2.pop(0)
242+
if parts_1 and not parts_2:
243+
if isinstance(path, PurePath):
244+
return type(path)(*parts_1)
245+
return type(path)(PurePath(*parts_1))
246+
return path

src/manage/startutils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from .fsutils import rmtree, unlink
44
from .logging import LOGGER
5-
from .pathutils import Path
5+
from .pathutils import Path, relative_to
66
from .tagutils import install_matches_any
77

88

@@ -36,7 +36,7 @@ def _make(root, prefix, item, allow_warn=True):
3636

3737
lnk = root / (n + ".lnk")
3838
target = _unprefix(item["Target"], prefix)
39-
LOGGER.debug("Creating shortcut %s to %s", lnk, target)
39+
LOGGER.debug("Creating shortcut %s to %s", relative_to(lnk, root), target)
4040
try:
4141
lnk.relative_to(root)
4242
except ValueError:

tests/test_alias.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Cmd:
1717
launcher_exe = "launcher.txt"
1818
launcherw_exe = "launcherw.txt"
1919
default_platform = "-64"
20+
force = False
2021

2122
def __init__(self, platform=None):
2223
self.scratch = {}
@@ -128,10 +129,7 @@ def test_write_alias_launcher_missing(fake_config, assert_log, tmp_path):
128129
target=tmp_path / "target.exe",
129130
)
130131
assert_log(
131-
"Checking for launcher.*",
132-
"Checking for launcher.*",
133-
"Checking for launcher.*",
134-
"Create %s linking to %s",
132+
"Create %s for %s using %s, chosen by %s",
135133
assert_log.end_of_log(),
136134
)
137135

@@ -160,7 +158,7 @@ def read_bytes():
160158
target=tmp_path / "target.exe",
161159
)
162160
assert_log(
163-
"Create %s linking to %s",
161+
"Create %s for %s",
164162
"Failed to read launcher template at %s\\.",
165163
"Failed to read %s",
166164
assert_log.end_of_log(),
@@ -183,8 +181,9 @@ def fake_link(x, y):
183181
_link=fake_link
184182
)
185183
assert_log(
186-
"Create %s linking to %s",
187-
"Failed to create hard link.+",
184+
"Create %s for %s",
185+
"Searching %s for suitable launcher to link",
186+
"No existing launcher available",
188187
"Created %s as copy of %s",
189188
assert_log.end_of_log(),
190189
)
@@ -217,8 +216,7 @@ def fake_link(x, y):
217216
_link=fake_link
218217
)
219218
assert_log(
220-
"Create %s linking to %s",
221-
"Failed to create hard link.+",
219+
"Create %s for %s",
222220
("Created %s as hard link to %s", ("test.exe", "actual_launcher.txt")),
223221
assert_log.end_of_log(),
224222
)
@@ -240,7 +238,7 @@ def test_write_alias_launcher_no_linking(fake_config, assert_log, tmp_path):
240238
_link=None
241239
)
242240
assert_log(
243-
"Create %s linking to %s",
241+
"Create %s for %s",
244242
("Created %s as copy of %s", ("test.exe", "launcher.txt")),
245243
assert_log.end_of_log(),
246244
)

tests/test_pathutils.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from manage.pathutils import Path, PurePath
3+
from manage.pathutils import Path, PurePath, relative_to
44

55
def test_path_match():
66
p = Path("python3.12.exe")
@@ -30,3 +30,22 @@ def test_path_stem():
3030
p = Path(".exe")
3131
assert p.stem == ""
3232
assert p.suffix == ".exe"
33+
34+
35+
def test_path_relative_to():
36+
p = Path(r"C:\A\B\C\python.exe")
37+
actual = relative_to(p, r"C:\A\B\C")
38+
assert isinstance(actual, Path)
39+
assert str(actual) == "python.exe"
40+
actual = relative_to(p, "C:\\")
41+
assert isinstance(actual, Path)
42+
assert str(actual) == r"A\B\C\python.exe"
43+
actual = relative_to(str(p), r"C:\A\B")
44+
assert isinstance(actual, str)
45+
assert actual == r"C\python.exe"
46+
actual = relative_to(bytes(p), r"C:\A\B")
47+
assert isinstance(actual, bytes)
48+
assert actual == rb"C\python.exe"
49+
50+
assert relative_to(p, r"C:\A\B\C\D") is p
51+
assert relative_to(p, None) is p

0 commit comments

Comments
 (0)