ixt-cli 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ixt/__init__.py +8 -0
- ixt/__main__.py +8 -0
- ixt/backends/__init__.py +1 -0
- ixt/backends/binary.py +935 -0
- ixt/backends/binary_resolver.py +307 -0
- ixt/backends/node.py +490 -0
- ixt/backends/python.py +234 -0
- ixt/cli/__init__.py +31 -0
- ixt/cli/argparse_completion.py +557 -0
- ixt/cli/cmd_apply.py +404 -0
- ixt/cli/cmd_cache.py +86 -0
- ixt/cli/cmd_config.py +295 -0
- ixt/cli/cmd_info.py +116 -0
- ixt/cli/cmd_install.py +508 -0
- ixt/cli/cmd_misc.py +261 -0
- ixt/cli/cmd_registry.py +35 -0
- ixt/cli/cmd_upgrade.py +336 -0
- ixt/cli/commands.py +70 -0
- ixt/cli/parser.py +555 -0
- ixt/cli/render.py +85 -0
- ixt/config/__init__.py +5 -0
- ixt/config/asset_index.py +305 -0
- ixt/config/asset_pattern_cache.py +87 -0
- ixt/config/env_policy.py +340 -0
- ixt/config/flags.py +29 -0
- ixt/config/fs_policy.py +17 -0
- ixt/config/heuristics.py +465 -0
- ixt/config/models.py +176 -0
- ixt/config/registry.py +145 -0
- ixt/config/settings.py +173 -0
- ixt/config/setup_toml.py +179 -0
- ixt/config/toml.py +416 -0
- ixt/core/__init__.py +16 -0
- ixt/core/apply.py +564 -0
- ixt/core/apply_actions.py +106 -0
- ixt/core/backend.py +187 -0
- ixt/core/bootstrap.py +410 -0
- ixt/core/cache.py +332 -0
- ixt/core/discover.py +150 -0
- ixt/core/doctor.py +591 -0
- ixt/core/export.py +419 -0
- ixt/core/expose.py +350 -0
- ixt/core/extract.py +261 -0
- ixt/core/hooks.py +182 -0
- ixt/core/identity.py +148 -0
- ixt/core/inject.py +143 -0
- ixt/core/install.py +509 -0
- ixt/core/install_local.py +229 -0
- ixt/core/locks.py +54 -0
- ixt/core/pathlink.py +86 -0
- ixt/core/resolution_stats.py +191 -0
- ixt/core/resolve.py +150 -0
- ixt/core/resolve_cache.py +185 -0
- ixt/core/runtimes.py +192 -0
- ixt/core/save.py +237 -0
- ixt/core/setup_completions.py +11 -0
- ixt/core/setup_path.py +368 -0
- ixt/core/upgrade.py +596 -0
- ixt/data/__init__.py +10 -0
- ixt/data/asset_index.json +574 -0
- ixt/data/heuristics.toml +98 -0
- ixt/data/registry.toml +71 -0
- ixt/libs/__init__.py +3 -0
- ixt/libs/constants.py +4 -0
- ixt/libs/fmt.py +108 -0
- ixt/libs/logger.py +109 -0
- ixt/libs/output.py +25 -0
- ixt/libs/req_spec.py +115 -0
- ixt/libs/semver.py +149 -0
- ixt/libs/shell.py +126 -0
- ixt/libs/style.py +238 -0
- ixt/net/__init__.py +1 -0
- ixt/net/github_api.py +158 -0
- ixt/net/gitlab_api.py +149 -0
- ixt/net/http.py +194 -0
- ixt/net/npm.py +24 -0
- ixt/net/pypi.py +26 -0
- ixt/net/source.py +163 -0
- ixt/platform/__init__.py +131 -0
- ixt/platform/win.py +68 -0
- ixt_cli-0.8.0.dist-info/METADATA +294 -0
- ixt_cli-0.8.0.dist-info/RECORD +84 -0
- ixt_cli-0.8.0.dist-info/WHEEL +4 -0
- ixt_cli-0.8.0.dist-info/entry_points.txt +2 -0
ixt/core/expose.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Exposure engine — resolve rules and link binaries to the shared bin dir."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ixt.config.heuristics import get_name_suffix_tokens, get_platform_aliases
|
|
10
|
+
from ixt.core.backend import Backend
|
|
11
|
+
from ixt.core.pathlink import PathLink, create_path_link
|
|
12
|
+
from ixt.libs.constants import EXPOSE_MAIN
|
|
13
|
+
|
|
14
|
+
# Binaries that should never be exposed (runtime internals).
|
|
15
|
+
_EXCLUDED_RE = re.compile(r"^(python|pythonw|pip)(\d+(\.\d+)?)?(.exe)?$")
|
|
16
|
+
_PLATFORM_SEPARATORS = "._-"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _strip_exe_suffix(name: str) -> str:
|
|
20
|
+
"""Return *name* without the Windows executable suffix, if present."""
|
|
21
|
+
return name[:-4] if name.lower().endswith(".exe") else name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _executable_name(path: Path) -> str:
|
|
25
|
+
"""Return the executable name used for rule matching."""
|
|
26
|
+
return _strip_exe_suffix(path.name)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _strip_suffix_tokens(name: str) -> str:
|
|
30
|
+
"""Remove OS/arch/build suffixes when they appear as trailing full tokens."""
|
|
31
|
+
os_aliases, arch_aliases = get_platform_aliases()
|
|
32
|
+
aliases = {
|
|
33
|
+
alias.lower()
|
|
34
|
+
for alias in [*os_aliases, *arch_aliases]
|
|
35
|
+
if alias and alias.strip(_PLATFORM_SEPARATORS)
|
|
36
|
+
}
|
|
37
|
+
aliases.update(
|
|
38
|
+
token.lower()
|
|
39
|
+
for token in get_name_suffix_tokens()
|
|
40
|
+
if token and token.strip(_PLATFORM_SEPARATORS)
|
|
41
|
+
)
|
|
42
|
+
stripped = name.strip(_PLATFORM_SEPARATORS)
|
|
43
|
+
changed = True
|
|
44
|
+
while stripped and changed:
|
|
45
|
+
changed = False
|
|
46
|
+
for alias in sorted(aliases, key=len, reverse=True):
|
|
47
|
+
token_re = re.compile(
|
|
48
|
+
rf"(?:^|[{re.escape(_PLATFORM_SEPARATORS)}])"
|
|
49
|
+
rf"{re.escape(alias)}$",
|
|
50
|
+
re.IGNORECASE,
|
|
51
|
+
)
|
|
52
|
+
next_stripped = token_re.sub("", stripped).strip(_PLATFORM_SEPARATORS)
|
|
53
|
+
if next_stripped != stripped:
|
|
54
|
+
stripped = next_stripped
|
|
55
|
+
changed = True
|
|
56
|
+
break
|
|
57
|
+
return stripped
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _eponym_alias(name: str, package_name: str) -> str | None:
|
|
61
|
+
"""Return *package_name* as alias when *name* is a suffixed variant.
|
|
62
|
+
|
|
63
|
+
Only aliases when removing the current suffix tokens leaves exactly
|
|
64
|
+
*package_name*. Returns None when *name* already equals *package_name*.
|
|
65
|
+
"""
|
|
66
|
+
if name == package_name:
|
|
67
|
+
return None
|
|
68
|
+
if _strip_suffix_tokens(name).lower() == package_name.lower():
|
|
69
|
+
return package_name
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_excluded(name: str) -> bool:
|
|
74
|
+
"""Return True if *name* is a runtime binary that must not be exposed."""
|
|
75
|
+
return bool(_EXCLUDED_RE.match(name))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_rename(rule: str) -> tuple[str, str | None]:
|
|
79
|
+
"""Parse ``"original:alias"`` → ``("original", "alias")``.
|
|
80
|
+
|
|
81
|
+
Plain names return ``("name", None)``.
|
|
82
|
+
"""
|
|
83
|
+
if ":" in rule:
|
|
84
|
+
orig, alias = rule.split(":", 1)
|
|
85
|
+
return orig.strip(), alias.strip()
|
|
86
|
+
return rule.strip(), None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Expose result
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(slots=True)
|
|
95
|
+
class ExposeResult:
|
|
96
|
+
"""Outcome of an expose operation for one tool."""
|
|
97
|
+
|
|
98
|
+
linked: dict[str, str] = field(default_factory=dict)
|
|
99
|
+
"""Map of exposed_name → source_path for successfully linked binaries."""
|
|
100
|
+
|
|
101
|
+
skipped: dict[str, str] = field(default_factory=dict)
|
|
102
|
+
"""Map of name → reason for binaries that were skipped."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Rule resolver
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _resolve_eponym(
|
|
111
|
+
package_name: str,
|
|
112
|
+
all_bins: list[Path],
|
|
113
|
+
) -> list[tuple[Path, str | None]]:
|
|
114
|
+
"""Find the binary matching *package_name* exactly or after platform-token cleanup."""
|
|
115
|
+
for p in all_bins:
|
|
116
|
+
if _executable_name(p) == package_name:
|
|
117
|
+
return [(p, None)]
|
|
118
|
+
for p in all_bins:
|
|
119
|
+
alias = _eponym_alias(_executable_name(p), package_name)
|
|
120
|
+
if alias is not None:
|
|
121
|
+
return [(p, alias)]
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_main(
|
|
126
|
+
package_name: str,
|
|
127
|
+
backend: Backend,
|
|
128
|
+
env_dir: Path,
|
|
129
|
+
all_bins: list[Path],
|
|
130
|
+
) -> list[tuple[Path, str | None]]:
|
|
131
|
+
"""Resolve ``__main__`` — declared console_scripts / bin entries."""
|
|
132
|
+
declared = backend.get_package_binaries(env_dir, package_name)
|
|
133
|
+
if not declared:
|
|
134
|
+
return []
|
|
135
|
+
bin_dir_entries = {_executable_name(p): p for p in all_bins}
|
|
136
|
+
result: list[tuple[Path, str | None]] = []
|
|
137
|
+
for name in declared:
|
|
138
|
+
executable_name = _strip_exe_suffix(name)
|
|
139
|
+
if _is_excluded(executable_name):
|
|
140
|
+
continue
|
|
141
|
+
if executable_name in bin_dir_entries:
|
|
142
|
+
result.append(
|
|
143
|
+
(
|
|
144
|
+
bin_dir_entries[executable_name],
|
|
145
|
+
_eponym_alias(executable_name, package_name),
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _resolve_all(
|
|
152
|
+
all_bins: list[Path],
|
|
153
|
+
) -> list[tuple[Path, str | None]]:
|
|
154
|
+
"""Resolve ``__all__`` — every binary except python/pip."""
|
|
155
|
+
return [(p, None) for p in all_bins if not _is_excluded(_executable_name(p))]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _resolve_explicit(
|
|
159
|
+
rules: list[str],
|
|
160
|
+
all_bins: list[Path],
|
|
161
|
+
) -> list[tuple[Path, str | None]]:
|
|
162
|
+
"""Resolve an explicit list of names, with optional rename syntax."""
|
|
163
|
+
bin_map = {_executable_name(p): p for p in all_bins}
|
|
164
|
+
result: list[tuple[Path, str | None]] = []
|
|
165
|
+
for rule in rules:
|
|
166
|
+
orig, alias = _parse_rename(rule)
|
|
167
|
+
executable_name = _strip_exe_suffix(orig)
|
|
168
|
+
if executable_name in bin_map:
|
|
169
|
+
result.append((bin_map[executable_name], alias))
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def resolve_rules(
|
|
174
|
+
rules: list[str],
|
|
175
|
+
package_name: str,
|
|
176
|
+
backend: Backend,
|
|
177
|
+
env_dir: Path,
|
|
178
|
+
) -> list[tuple[Path, str | None]]:
|
|
179
|
+
"""Resolve expose *rules* into a list of ``(source_path, alias_or_None)``.
|
|
180
|
+
|
|
181
|
+
Implements the fallback chain when the first rule is a single keyword:
|
|
182
|
+
``__eponym__`` → ``__main__`` → ``__eponym__`` retry → ``__all__``
|
|
183
|
+
"""
|
|
184
|
+
all_bins = backend.find_binaries(env_dir)
|
|
185
|
+
|
|
186
|
+
# Special keyword rules (single-element list with a dunder keyword).
|
|
187
|
+
if len(rules) == 1 and rules[0].startswith("__"):
|
|
188
|
+
keyword = rules[0]
|
|
189
|
+
|
|
190
|
+
if keyword == "__all__":
|
|
191
|
+
return _resolve_all(all_bins)
|
|
192
|
+
|
|
193
|
+
if keyword == EXPOSE_MAIN:
|
|
194
|
+
result = _resolve_main(package_name, backend, env_dir, all_bins)
|
|
195
|
+
if result:
|
|
196
|
+
return result
|
|
197
|
+
# fallback → __eponym__
|
|
198
|
+
result = _resolve_eponym(package_name, all_bins)
|
|
199
|
+
if result:
|
|
200
|
+
return result
|
|
201
|
+
# fallback → __all__
|
|
202
|
+
return _resolve_all(all_bins)
|
|
203
|
+
|
|
204
|
+
if keyword == "__eponym__":
|
|
205
|
+
result = _resolve_eponym(package_name, all_bins)
|
|
206
|
+
if result:
|
|
207
|
+
return result
|
|
208
|
+
# fallback → __main__
|
|
209
|
+
result = _resolve_main(package_name, backend, env_dir, all_bins)
|
|
210
|
+
if result:
|
|
211
|
+
return result
|
|
212
|
+
# fallback → __all__
|
|
213
|
+
return _resolve_all(all_bins)
|
|
214
|
+
|
|
215
|
+
# Explicit list (possibly with rename syntax).
|
|
216
|
+
return _resolve_explicit(rules, all_bins)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
# Public API
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def expose_tool(
|
|
225
|
+
package_name: str,
|
|
226
|
+
backend: Backend,
|
|
227
|
+
env_dir: Path,
|
|
228
|
+
bin_dir: Path,
|
|
229
|
+
rules: list[str] | None = None,
|
|
230
|
+
*,
|
|
231
|
+
overwrite: bool = False,
|
|
232
|
+
) -> ExposeResult:
|
|
233
|
+
"""Expose binaries for one tool according to *rules*.
|
|
234
|
+
|
|
235
|
+
Returns an `ExposeResult` describing what was linked and what was skipped.
|
|
236
|
+
Collision reasons include the owning tool name when available.
|
|
237
|
+
"""
|
|
238
|
+
rules = rules or [EXPOSE_MAIN]
|
|
239
|
+
resolved = resolve_rules(rules, package_name, backend, env_dir)
|
|
240
|
+
result = ExposeResult()
|
|
241
|
+
|
|
242
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
|
|
244
|
+
for source, alias in resolved:
|
|
245
|
+
link = create_path_link(source, bin_dir, rename_to=alias)
|
|
246
|
+
exposed_name = alias or _executable_name(source)
|
|
247
|
+
|
|
248
|
+
if link.target_exists() and not overwrite:
|
|
249
|
+
# Check if it's our own (idempotent re-expose).
|
|
250
|
+
if link.is_valid():
|
|
251
|
+
result.linked[exposed_name] = str(source)
|
|
252
|
+
else:
|
|
253
|
+
owner = _find_shim_owner(exposed_name, bin_dir, exclude_env_dir=env_dir)
|
|
254
|
+
reason = (
|
|
255
|
+
f"conflict: already exposed by {owner}" if owner else "conflict: already exists"
|
|
256
|
+
)
|
|
257
|
+
result.skipped[exposed_name] = reason
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
link.create(overwrite=overwrite)
|
|
261
|
+
result.linked[exposed_name] = str(source)
|
|
262
|
+
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def warn_if_skipped(result: ExposeResult, tool_name: str, logger) -> None:
|
|
267
|
+
"""Log a warning and an actionable hint when expose produced collisions."""
|
|
268
|
+
if not result.skipped:
|
|
269
|
+
return
|
|
270
|
+
if len(result.skipped) == 1:
|
|
271
|
+
name, reason = next(iter(sorted(result.skipped.items())))
|
|
272
|
+
logger.warn(f"could not expose: {name} ({reason})")
|
|
273
|
+
else:
|
|
274
|
+
logger.warn("could not expose:")
|
|
275
|
+
for name, reason in sorted(result.skipped.items()):
|
|
276
|
+
logger.warn(f" {name}: {reason}")
|
|
277
|
+
example = next(iter(sorted(result.skipped)))
|
|
278
|
+
logger.warn(f" Hint: run ixt tool config {tool_name} expose {example}:<alias>")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _find_shim_owner(exposed_name: str, bin_dir: Path, *, exclude_env_dir: Path) -> str | None:
|
|
282
|
+
"""Return the name of the tool whose record claims *exposed_name*, or None.
|
|
283
|
+
|
|
284
|
+
Scans installed tool metadata under ``bin_dir.parent / "envs"``. Skips
|
|
285
|
+
``exclude_env_dir`` so a reinstall doesn't report itself as the owner.
|
|
286
|
+
"""
|
|
287
|
+
envs_dir = bin_dir.parent / "envs"
|
|
288
|
+
if not envs_dir.is_dir():
|
|
289
|
+
return None
|
|
290
|
+
exclude_resolved = exclude_env_dir.resolve() if exclude_env_dir.exists() else exclude_env_dir
|
|
291
|
+
from ixt.config.models import ToolRecord
|
|
292
|
+
|
|
293
|
+
for env_path in envs_dir.iterdir():
|
|
294
|
+
if not env_path.is_dir():
|
|
295
|
+
continue
|
|
296
|
+
if env_path.resolve() == exclude_resolved:
|
|
297
|
+
continue
|
|
298
|
+
meta = env_path / "ixt.json"
|
|
299
|
+
if not meta.is_file():
|
|
300
|
+
continue
|
|
301
|
+
try:
|
|
302
|
+
record = ToolRecord.load_json(meta)
|
|
303
|
+
except Exception:
|
|
304
|
+
continue
|
|
305
|
+
if exposed_name in record.exposed_bins:
|
|
306
|
+
return record.name
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def unexpose_tool(
|
|
311
|
+
exposed_bins: dict[str, str],
|
|
312
|
+
bin_dir: Path,
|
|
313
|
+
) -> list[str]:
|
|
314
|
+
"""Remove previously exposed links for one tool.
|
|
315
|
+
|
|
316
|
+
*exposed_bins* maps ``exposed_name → source_path`` (from ``ToolRecord``).
|
|
317
|
+
Returns the list of names successfully removed.
|
|
318
|
+
"""
|
|
319
|
+
removed: list[str] = []
|
|
320
|
+
for exposed_name, source_str in exposed_bins.items():
|
|
321
|
+
link = PathLink(
|
|
322
|
+
source_path=Path(source_str),
|
|
323
|
+
target_path=bin_dir / exposed_name,
|
|
324
|
+
)
|
|
325
|
+
if link.remove(force=True):
|
|
326
|
+
removed.append(exposed_name)
|
|
327
|
+
return removed
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def reexpose_tool(record, new_rules: list[str], settings) -> ExposeResult:
|
|
331
|
+
"""Unexpose then re-expose *record* with *new_rules*, and persist it."""
|
|
332
|
+
from ixt.core.backend import BackendType, get_backend
|
|
333
|
+
|
|
334
|
+
env_dir = Path(record.env_dir)
|
|
335
|
+
bt = BackendType(record.backend)
|
|
336
|
+
backend = get_backend(bt, settings=settings)
|
|
337
|
+
|
|
338
|
+
unexpose_tool(record.exposed_bins, settings.bin_dir)
|
|
339
|
+
result = expose_tool(
|
|
340
|
+
record.pkg(),
|
|
341
|
+
backend,
|
|
342
|
+
env_dir,
|
|
343
|
+
settings.bin_dir,
|
|
344
|
+
new_rules,
|
|
345
|
+
overwrite=True,
|
|
346
|
+
)
|
|
347
|
+
record.expose_rules = list(new_rules)
|
|
348
|
+
record.exposed_bins = result.linked
|
|
349
|
+
record.save_json(settings.get_tool_metadata_file(record.name))
|
|
350
|
+
return result
|
ixt/core/extract.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Archive extraction with anti-traversal protection.
|
|
2
|
+
|
|
3
|
+
Supports: .tar.gz, .tar.xz, .tar.bz2, .tgz, .txz, .zip, bare binaries.
|
|
4
|
+
All stdlib — no third-party dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import tarfile
|
|
13
|
+
import zipfile
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
TarReadMode = Literal["r:gz", "r:xz", "r:bz2"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExtractionError(Exception):
|
|
22
|
+
"""Raised on extraction failure (bad archive, traversal attempt, etc.)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class ExtractionResult:
|
|
27
|
+
"""Outcome of an extraction operation."""
|
|
28
|
+
|
|
29
|
+
root: Path
|
|
30
|
+
"""The effective root directory containing the extracted content."""
|
|
31
|
+
|
|
32
|
+
files: list[Path]
|
|
33
|
+
"""All extracted file paths (relative to *root*)."""
|
|
34
|
+
|
|
35
|
+
is_bare_binary: bool = False
|
|
36
|
+
"""True when the asset was a bare binary (no archive)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# -- Archive format detection -------------------------------------------------
|
|
40
|
+
|
|
41
|
+
_TAR_EXTENSIONS: dict[str, TarReadMode] = {
|
|
42
|
+
".tar.gz": "r:gz",
|
|
43
|
+
".tgz": "r:gz",
|
|
44
|
+
".tar.xz": "r:xz",
|
|
45
|
+
".txz": "r:xz",
|
|
46
|
+
".tar.bz2": "r:bz2",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_ZIP_EXTENSIONS = {".zip"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _archive_mode(filename: str) -> TarReadMode | None:
|
|
53
|
+
"""Return tarfile open mode if *filename* is a supported tar, else None."""
|
|
54
|
+
lower = filename.lower()
|
|
55
|
+
for ext, mode in _TAR_EXTENSIONS.items():
|
|
56
|
+
if lower.endswith(ext):
|
|
57
|
+
return mode
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_zip(filename: str) -> bool:
|
|
62
|
+
return filename.lower().endswith(".zip")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# -- Anti-traversal -----------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _safe_path(member_name: str, dest: Path) -> Path:
|
|
69
|
+
"""Resolve *member_name* under *dest* and reject path traversal.
|
|
70
|
+
|
|
71
|
+
Raises ExtractionError if the resolved path escapes *dest*.
|
|
72
|
+
"""
|
|
73
|
+
# Normalise: strip leading / and ./ , collapse ..
|
|
74
|
+
cleaned = os.path.normpath(member_name)
|
|
75
|
+
if cleaned.startswith(("../", "..\\")):
|
|
76
|
+
raise ExtractionError(f"Path traversal detected: {member_name!r}")
|
|
77
|
+
|
|
78
|
+
resolved = (dest / cleaned).resolve()
|
|
79
|
+
dest_resolved = dest.resolve()
|
|
80
|
+
|
|
81
|
+
if not str(resolved).startswith(str(dest_resolved) + os.sep) and resolved != dest_resolved:
|
|
82
|
+
raise ExtractionError(f"Path traversal detected: {member_name!r} -> {resolved}")
|
|
83
|
+
|
|
84
|
+
return resolved
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# -- Tar extraction -----------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _extract_tar(archive_path: Path, dest: Path, mode: TarReadMode) -> list[Path]:
|
|
91
|
+
"""Extract a tar archive, returning list of extracted files (relative)."""
|
|
92
|
+
files: list[Path] = []
|
|
93
|
+
|
|
94
|
+
with tarfile.open(archive_path, mode) as tf:
|
|
95
|
+
for member in tf.getmembers():
|
|
96
|
+
# Skip special file types (devices, etc.)
|
|
97
|
+
if not (member.isfile() or member.isdir() or member.issym()):
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
target = _safe_path(member.name, dest)
|
|
101
|
+
|
|
102
|
+
if member.isdir():
|
|
103
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
elif member.issym():
|
|
105
|
+
# Validate symlink target stays within dest
|
|
106
|
+
link_target = os.path.normpath(
|
|
107
|
+
os.path.join(os.path.dirname(member.name), member.linkname)
|
|
108
|
+
)
|
|
109
|
+
_safe_path(link_target, dest)
|
|
110
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
if target.exists() or target.is_symlink():
|
|
112
|
+
target.unlink()
|
|
113
|
+
os.symlink(member.linkname, target)
|
|
114
|
+
else:
|
|
115
|
+
# Regular file
|
|
116
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
src = tf.extractfile(member)
|
|
118
|
+
if src is None:
|
|
119
|
+
continue
|
|
120
|
+
with src, target.open("wb") as dst:
|
|
121
|
+
shutil.copyfileobj(src, dst)
|
|
122
|
+
|
|
123
|
+
# Preserve executable bit
|
|
124
|
+
if member.mode & 0o111:
|
|
125
|
+
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
126
|
+
|
|
127
|
+
files.append(Path(member.name))
|
|
128
|
+
|
|
129
|
+
return files
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# -- Zip extraction -----------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _extract_zip(archive_path: Path, dest: Path) -> list[Path]:
|
|
136
|
+
"""Extract a zip archive, returning list of extracted files (relative)."""
|
|
137
|
+
files: list[Path] = []
|
|
138
|
+
|
|
139
|
+
with zipfile.ZipFile(archive_path, "r") as zf:
|
|
140
|
+
for info in zf.infolist():
|
|
141
|
+
target = _safe_path(info.filename, dest)
|
|
142
|
+
|
|
143
|
+
if info.is_dir():
|
|
144
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
|
|
149
|
+
with zf.open(info) as src, target.open("wb") as dst:
|
|
150
|
+
shutil.copyfileobj(src, dst)
|
|
151
|
+
|
|
152
|
+
# Preserve Unix executable bit from external_attr
|
|
153
|
+
unix_mode = info.external_attr >> 16
|
|
154
|
+
if unix_mode & 0o111:
|
|
155
|
+
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
156
|
+
|
|
157
|
+
files.append(Path(info.filename))
|
|
158
|
+
|
|
159
|
+
return files
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# -- Bare binary --------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _copy_bare_binary(src: Path, dest: Path, name: str) -> list[Path]:
|
|
166
|
+
"""Copy a bare binary (no archive) into *dest* and make it executable."""
|
|
167
|
+
target = _safe_path(name, dest)
|
|
168
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
with src.open("rb") as source, target.open("wb") as out:
|
|
170
|
+
shutil.copyfileobj(source, out)
|
|
171
|
+
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
172
|
+
return [Path(name)]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# -- Root normalisation -------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _normalise_root(dest: Path, files: list[Path]) -> tuple[Path, list[Path]]:
|
|
179
|
+
"""If all files share a single top-level directory, descend into it.
|
|
180
|
+
|
|
181
|
+
Many archives wrap everything in a directory like ``ripgrep-14.1.0/``.
|
|
182
|
+
This returns the effective root and rebased file paths.
|
|
183
|
+
"""
|
|
184
|
+
if not files:
|
|
185
|
+
return dest, files
|
|
186
|
+
|
|
187
|
+
# Collect unique top-level components
|
|
188
|
+
top_dirs: set[str] = set()
|
|
189
|
+
for f in files:
|
|
190
|
+
top_dirs.add(f.parts[0])
|
|
191
|
+
|
|
192
|
+
if len(top_dirs) != 1:
|
|
193
|
+
return dest, files
|
|
194
|
+
|
|
195
|
+
# Single top-level directory — check it's actually a directory
|
|
196
|
+
wrapper = top_dirs.pop()
|
|
197
|
+
wrapper_path = dest / wrapper
|
|
198
|
+
if not wrapper_path.is_dir():
|
|
199
|
+
return dest, files
|
|
200
|
+
|
|
201
|
+
# Rebase files relative to the wrapper
|
|
202
|
+
rebased: list[Path] = []
|
|
203
|
+
for f in files:
|
|
204
|
+
parts = f.parts[1:] # strip wrapper prefix
|
|
205
|
+
if parts:
|
|
206
|
+
rebased.append(Path(*parts))
|
|
207
|
+
|
|
208
|
+
return wrapper_path, rebased
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# -- Public API ---------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def is_archive(filename: str) -> bool:
|
|
215
|
+
"""Return True if *filename* has a recognized archive extension."""
|
|
216
|
+
return _archive_mode(filename) is not None or _is_zip(filename)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def extract(
|
|
220
|
+
archive_path: Path,
|
|
221
|
+
dest: Path,
|
|
222
|
+
*,
|
|
223
|
+
tool_name: str | None = None,
|
|
224
|
+
) -> ExtractionResult:
|
|
225
|
+
"""Extract an archive or copy a bare binary into *dest*.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
archive_path: Path to the downloaded archive or bare binary.
|
|
229
|
+
dest: Directory to extract into (created if needed).
|
|
230
|
+
tool_name: Tool name used as filename for bare binaries.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
ExtractionResult with the effective root and file listing.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ExtractionError: On path traversal, bad archive, or I/O failure.
|
|
237
|
+
"""
|
|
238
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
name = archive_path.name
|
|
240
|
+
|
|
241
|
+
tar_mode = _archive_mode(name)
|
|
242
|
+
if tar_mode is not None:
|
|
243
|
+
try:
|
|
244
|
+
files = _extract_tar(archive_path, dest, tar_mode)
|
|
245
|
+
except (tarfile.TarError, OSError) as exc:
|
|
246
|
+
raise ExtractionError(f"Failed to extract tar: {exc}") from exc
|
|
247
|
+
root, files = _normalise_root(dest, files)
|
|
248
|
+
return ExtractionResult(root=root, files=files)
|
|
249
|
+
|
|
250
|
+
if _is_zip(name):
|
|
251
|
+
try:
|
|
252
|
+
files = _extract_zip(archive_path, dest)
|
|
253
|
+
except (zipfile.BadZipFile, OSError) as exc:
|
|
254
|
+
raise ExtractionError(f"Failed to extract zip: {exc}") from exc
|
|
255
|
+
root, files = _normalise_root(dest, files)
|
|
256
|
+
return ExtractionResult(root=root, files=files)
|
|
257
|
+
|
|
258
|
+
# Bare binary — not an archive
|
|
259
|
+
bin_name = tool_name or archive_path.stem
|
|
260
|
+
files = _copy_bare_binary(archive_path, dest, bin_name)
|
|
261
|
+
return ExtractionResult(root=dest, files=files, is_bare_binary=True)
|