graphite-code 0.3.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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1597 @@
|
|
|
1
|
+
"""Fail-closed primitives for consent-gated local dependency installation.
|
|
2
|
+
|
|
3
|
+
This module deliberately contains no activation policy or user interaction. It
|
|
4
|
+
only prepares and validates immutable commands, files, environments, and bounded
|
|
5
|
+
process results for the higher-level activation service.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import stat
|
|
15
|
+
from collections import deque
|
|
16
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.parse import urlsplit
|
|
22
|
+
|
|
23
|
+
from .probe_process import ProbeProcessError, ProbeProcessResult, run_bounded_process
|
|
24
|
+
|
|
25
|
+
TRUSTED_REGISTRY = "https://registry.npmjs.org/"
|
|
26
|
+
INSTALL_OUTPUT_LIMIT = 64 * 1024
|
|
27
|
+
#: Budget for `<manager> --version`. Sized for the NOISE, not for the answer:
|
|
28
|
+
#: `run_bounded_process` applies its limit per stream, so whatever the child
|
|
29
|
+
#: writes to stderr competes with the same number, and overflow is reported as
|
|
30
|
+
#: an unavailable manager. A version string needs tens of bytes; npm notices,
|
|
31
|
+
#: Node deprecation warnings and interpreter warnings need hundreds to a few
|
|
32
|
+
#: thousand, and `_minimal_node_environment` silences none of them. Still a
|
|
33
|
+
#: hard flood bound -- three orders of magnitude under the install budget.
|
|
34
|
+
MANAGER_VERSION_OUTPUT_LIMIT = 8 * 1024
|
|
35
|
+
MAX_CONTROL_FILE_BYTES = 8 * 1024 * 1024
|
|
36
|
+
MAX_TRUSTED_FILE_BYTES = 256 * 1024 * 1024
|
|
37
|
+
MAX_TRUSTED_LAUNCHER_LINKS = 8
|
|
38
|
+
MAX_TRUSTED_LINK_TARGET_BYTES = 4096
|
|
39
|
+
MAX_TRUSTED_ROUTE_COMPONENTS = 256
|
|
40
|
+
MAX_TRUSTED_EXECUTABLE_PREFIX_BYTES = 256
|
|
41
|
+
ACTIVATION_MAX_FILES = 100_000
|
|
42
|
+
|
|
43
|
+
_VERSION_RE = re.compile(
|
|
44
|
+
r"v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
45
|
+
r"(?:-(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*))*)?"
|
|
46
|
+
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
|
|
47
|
+
)
|
|
48
|
+
_LOCALE_ENVIRONMENT = ("LANG", "LC_ALL")
|
|
49
|
+
_DEPENDENCY_FIELDS = ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies")
|
|
50
|
+
_FORBIDDEN_MANIFEST_FIELDS = frozenset(
|
|
51
|
+
{"workspaces", "resolutions", "overrides", "pnpm", "installConfig", "publishConfig"}
|
|
52
|
+
)
|
|
53
|
+
_URI_RE = re.compile(r"([A-Za-z][A-Za-z0-9+.-]*)://[^\s\"'<>}\]]+")
|
|
54
|
+
_LOCKFILE_LINE_LIMIT = 64 * 1024
|
|
55
|
+
_MAPPING_LINE_RE = re.compile(
|
|
56
|
+
r"^(?:\"[^\"]+\"|'(?:[^']|'')+'|[A-Za-z0-9_./@+*^~<>=,!|()-]+):(?:\s+(.+)|\s*)$"
|
|
57
|
+
)
|
|
58
|
+
_SOURCE_FIELD_KEYS = frozenset({"resolved", "tarball", "fetch", "source", "path", "url"})
|
|
59
|
+
_LOCAL_TYPESCRIPT_SCRIPT = (
|
|
60
|
+
"const p=require.resolve('typescript/package.json',{paths:[process.cwd()]});"
|
|
61
|
+
"process.stdout.write(JSON.stringify({resolved:p}));"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Manager(StrEnum):
|
|
66
|
+
NPM = "npm"
|
|
67
|
+
PNPM = "pnpm"
|
|
68
|
+
YARN = "yarn"
|
|
69
|
+
BUN = "bun"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Version:
|
|
74
|
+
major: int
|
|
75
|
+
minor: int
|
|
76
|
+
patch: int
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class ManagerAdapter:
|
|
81
|
+
manager: Manager
|
|
82
|
+
lockfiles: tuple[str, ...]
|
|
83
|
+
supported_majors: frozenset[int]
|
|
84
|
+
command_builder: Callable[[str], tuple[str, ...]]
|
|
85
|
+
unsafe_root_files: tuple[str, ...]
|
|
86
|
+
automatic: bool = True
|
|
87
|
+
|
|
88
|
+
def supports(self, version: Version | None) -> bool:
|
|
89
|
+
return self.automatic and version is not None and version.major in self.supported_majors
|
|
90
|
+
|
|
91
|
+
def argument_tail(self, registry: str) -> tuple[str, ...]:
|
|
92
|
+
return self.command_builder(registry)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class TrustedPathComponent:
|
|
97
|
+
path: Path
|
|
98
|
+
identity: tuple[int, int]
|
|
99
|
+
mode: int
|
|
100
|
+
owner: int
|
|
101
|
+
link_target: str | None
|
|
102
|
+
link_count: int | None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class TrustedFile:
|
|
107
|
+
path: Path
|
|
108
|
+
identity: tuple[int, int]
|
|
109
|
+
size: int
|
|
110
|
+
mtime_ns: int
|
|
111
|
+
ctime_ns: int | None
|
|
112
|
+
sha256: str
|
|
113
|
+
launcher_path: Path | None = None
|
|
114
|
+
launcher_route: tuple[TrustedPathComponent, ...] = ()
|
|
115
|
+
#: The launcher name is trusted by the TARGET's identity and content rather
|
|
116
|
+
#: than by path trust over every route component, so it carries no route.
|
|
117
|
+
#: Legitimate only for the interpreter this process is already running --
|
|
118
|
+
#: see `_trusted_identity_launcher`. Anything else must earn a full route.
|
|
119
|
+
launcher_identity_only: bool = False
|
|
120
|
+
prefix: bytes = b""
|
|
121
|
+
|
|
122
|
+
def __post_init__(self) -> None:
|
|
123
|
+
if self.launcher_identity_only:
|
|
124
|
+
# A route would be meaningless here, and its absence is the marker
|
|
125
|
+
# revalidation reads, so an identity launcher must carry a name and
|
|
126
|
+
# no route -- not merely "one or the other".
|
|
127
|
+
paired = self.launcher_path is not None and not self.launcher_route
|
|
128
|
+
else:
|
|
129
|
+
paired = (self.launcher_path is None) == (not self.launcher_route)
|
|
130
|
+
if (
|
|
131
|
+
not paired
|
|
132
|
+
or not isinstance(self.prefix, bytes)
|
|
133
|
+
or len(self.prefix) > MAX_TRUSTED_EXECUTABLE_PREFIX_BYTES
|
|
134
|
+
):
|
|
135
|
+
raise ValueError("trusted_file_launcher_invalid")
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def command_path(self) -> Path:
|
|
139
|
+
return self.launcher_path if self.launcher_path is not None else self.path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class FileSnapshot:
|
|
144
|
+
relative_path: str
|
|
145
|
+
identity: tuple[int, int]
|
|
146
|
+
sha256: str
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class StepResult:
|
|
151
|
+
ok: bool
|
|
152
|
+
reason: str
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class VersionResult:
|
|
157
|
+
ok: bool
|
|
158
|
+
reason: str
|
|
159
|
+
version: Version | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class TrustedCommand:
|
|
164
|
+
"""An argv prefix and every external file on which that prefix depends.
|
|
165
|
+
|
|
166
|
+
The first reference is always the OS executable. Remaining references are
|
|
167
|
+
pinned files consumed by it, including a POSIX manager's lexical script
|
|
168
|
+
route or the Windows npm CLI.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
argv: tuple[str, ...]
|
|
172
|
+
references: tuple[TrustedFile, ...]
|
|
173
|
+
|
|
174
|
+
def __post_init__(self) -> None:
|
|
175
|
+
reference_arguments = tuple(str(reference.command_path) for reference in self.references)
|
|
176
|
+
if (
|
|
177
|
+
not self.argv
|
|
178
|
+
or not self.references
|
|
179
|
+
or self.argv != reference_arguments
|
|
180
|
+
or (os.name == "nt" and self.references[0].path.suffix.lower() not in {".exe", ".com"})
|
|
181
|
+
):
|
|
182
|
+
raise ValueError("trusted_command_invalid")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
Runner = Callable[..., ProbeProcessResult]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _npm_tail(registry: str) -> tuple[str, ...]:
|
|
189
|
+
return (
|
|
190
|
+
"install",
|
|
191
|
+
"--save-dev",
|
|
192
|
+
"--ignore-scripts",
|
|
193
|
+
"--no-audit",
|
|
194
|
+
"--no-fund",
|
|
195
|
+
f"--registry={registry}",
|
|
196
|
+
"typescript",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _pnpm_tail(registry: str) -> tuple[str, ...]:
|
|
201
|
+
return (
|
|
202
|
+
"add",
|
|
203
|
+
"--save-dev",
|
|
204
|
+
"--ignore-scripts",
|
|
205
|
+
"--ignore-workspace-root-check",
|
|
206
|
+
f"--registry={registry}",
|
|
207
|
+
"typescript",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _yarn_tail(_registry: str) -> tuple[str, ...]:
|
|
212
|
+
return ("add", "--dev", "--mode=skip-build", "typescript")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _bun_tail(registry: str) -> tuple[str, ...]:
|
|
216
|
+
return ("add", "--dev", "--ignore-scripts", "--registry", registry, "typescript")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
_ADAPTERS = {
|
|
220
|
+
Manager.NPM: ManagerAdapter(
|
|
221
|
+
Manager.NPM,
|
|
222
|
+
("package-lock.json",),
|
|
223
|
+
frozenset(range(8, 12)),
|
|
224
|
+
_npm_tail,
|
|
225
|
+
(".npmrc", "npm-shrinkwrap.json"),
|
|
226
|
+
),
|
|
227
|
+
Manager.PNPM: ManagerAdapter(
|
|
228
|
+
Manager.PNPM,
|
|
229
|
+
("pnpm-lock.yaml",),
|
|
230
|
+
frozenset({11}),
|
|
231
|
+
_pnpm_tail,
|
|
232
|
+
(".npmrc", "pnpm-workspace.yaml", ".pnpmfile.cjs", ".pnpmfile.mjs"),
|
|
233
|
+
),
|
|
234
|
+
Manager.YARN: ManagerAdapter(
|
|
235
|
+
Manager.YARN,
|
|
236
|
+
("yarn.lock",),
|
|
237
|
+
frozenset(),
|
|
238
|
+
_yarn_tail,
|
|
239
|
+
(".yarnrc.yml", ".yarnrc", ".yarn/plugins"),
|
|
240
|
+
automatic=False,
|
|
241
|
+
),
|
|
242
|
+
Manager.BUN: ManagerAdapter(
|
|
243
|
+
Manager.BUN, ("bun.lock", "bun.lockb"), frozenset({1}), _bun_tail, (".npmrc", "bunfig.toml")
|
|
244
|
+
),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def adapter_for(manager: Manager) -> ManagerAdapter:
|
|
249
|
+
return _ADAPTERS[Manager(manager)]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def parse_version(value: str | bytes) -> Version | None:
|
|
253
|
+
try:
|
|
254
|
+
text = value.decode("ascii") if isinstance(value, bytes) else value
|
|
255
|
+
except (UnicodeDecodeError, AttributeError):
|
|
256
|
+
return None
|
|
257
|
+
match = _VERSION_RE.fullmatch(text.strip())
|
|
258
|
+
if match is None:
|
|
259
|
+
return None
|
|
260
|
+
return Version(*(int(part) for part in match.groups()[:3]))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _is_reparse(stat_result: os.stat_result) -> bool:
|
|
264
|
+
attributes = getattr(stat_result, "st_file_attributes", 0)
|
|
265
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
266
|
+
return bool(attributes & reparse_flag)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _is_under(path: Path, root: Path) -> bool:
|
|
270
|
+
try:
|
|
271
|
+
path.relative_to(root)
|
|
272
|
+
except ValueError:
|
|
273
|
+
return False
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _path_has_symlink(path: Path) -> bool:
|
|
278
|
+
current = Path(path.anchor)
|
|
279
|
+
try:
|
|
280
|
+
for part in path.parts[1:]:
|
|
281
|
+
current /= part
|
|
282
|
+
details = current.lstat()
|
|
283
|
+
if stat.S_ISLNK(details.st_mode) or _is_reparse(details):
|
|
284
|
+
return True
|
|
285
|
+
except OSError:
|
|
286
|
+
return True
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _trusted_posix_owner(owner: int) -> bool:
|
|
291
|
+
try:
|
|
292
|
+
effective_uid = os.geteuid()
|
|
293
|
+
except AttributeError:
|
|
294
|
+
return False
|
|
295
|
+
return owner in {0, effective_uid}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _capture_posix_component(path: Path) -> TrustedPathComponent | None:
|
|
299
|
+
try:
|
|
300
|
+
lexical = Path(os.path.abspath(path))
|
|
301
|
+
before = lexical.lstat()
|
|
302
|
+
if _is_reparse(before):
|
|
303
|
+
return None
|
|
304
|
+
link_target = None
|
|
305
|
+
if stat.S_ISLNK(before.st_mode):
|
|
306
|
+
if before.st_size > MAX_TRUSTED_LINK_TARGET_BYTES:
|
|
307
|
+
return None
|
|
308
|
+
link_target = os.readlink(lexical)
|
|
309
|
+
if len(os.fsencode(link_target)) > MAX_TRUSTED_LINK_TARGET_BYTES:
|
|
310
|
+
return None
|
|
311
|
+
after = lexical.lstat()
|
|
312
|
+
except (OSError, RuntimeError, ValueError):
|
|
313
|
+
return None
|
|
314
|
+
stable_fields = (
|
|
315
|
+
"st_dev",
|
|
316
|
+
"st_ino",
|
|
317
|
+
"st_mode",
|
|
318
|
+
"st_size",
|
|
319
|
+
"st_mtime_ns",
|
|
320
|
+
"st_ctime_ns",
|
|
321
|
+
"st_nlink",
|
|
322
|
+
"st_uid",
|
|
323
|
+
)
|
|
324
|
+
if any(getattr(before, field, None) != getattr(after, field, None) for field in stable_fields):
|
|
325
|
+
return None
|
|
326
|
+
return TrustedPathComponent(
|
|
327
|
+
lexical,
|
|
328
|
+
(before.st_dev, before.st_ino),
|
|
329
|
+
before.st_mode,
|
|
330
|
+
before.st_uid,
|
|
331
|
+
link_target,
|
|
332
|
+
None if stat.S_ISDIR(before.st_mode) else before.st_nlink,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _trusted_posix_component(
|
|
337
|
+
component: TrustedPathComponent, *, directory: bool
|
|
338
|
+
) -> bool:
|
|
339
|
+
if not _trusted_posix_owner(component.owner):
|
|
340
|
+
return False
|
|
341
|
+
if component.link_target is not None:
|
|
342
|
+
return component.link_count == 1
|
|
343
|
+
if directory:
|
|
344
|
+
if not stat.S_ISDIR(component.mode):
|
|
345
|
+
return False
|
|
346
|
+
writable = bool(component.mode & (stat.S_IWGRP | stat.S_IWOTH))
|
|
347
|
+
return not writable or bool(component.mode & stat.S_ISVTX)
|
|
348
|
+
return (
|
|
349
|
+
stat.S_ISREG(component.mode)
|
|
350
|
+
and component.link_count == 1
|
|
351
|
+
and not component.mode & (stat.S_IWGRP | stat.S_IWOTH)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _trusted_posix_route(
|
|
356
|
+
path: Path, root: Path
|
|
357
|
+
) -> tuple[Path, tuple[TrustedPathComponent, ...]] | None:
|
|
358
|
+
try:
|
|
359
|
+
lexical = Path(os.path.abspath(path))
|
|
360
|
+
root_path = root.resolve(strict=True)
|
|
361
|
+
except (OSError, RuntimeError):
|
|
362
|
+
return None
|
|
363
|
+
if not lexical.is_absolute() or _is_under(lexical, root_path):
|
|
364
|
+
return None
|
|
365
|
+
anchor = Path(lexical.anchor)
|
|
366
|
+
anchor_component = _capture_posix_component(anchor)
|
|
367
|
+
if anchor_component is None or not _trusted_posix_component(
|
|
368
|
+
anchor_component, directory=True
|
|
369
|
+
):
|
|
370
|
+
return None
|
|
371
|
+
route = [anchor_component]
|
|
372
|
+
pending = deque(lexical.parts[1:])
|
|
373
|
+
current = anchor
|
|
374
|
+
seen_links: set[tuple[int, int]] = set()
|
|
375
|
+
followed_links = 0
|
|
376
|
+
while pending:
|
|
377
|
+
part = pending.popleft()
|
|
378
|
+
if part in {"", "."}:
|
|
379
|
+
continue
|
|
380
|
+
if part == "..":
|
|
381
|
+
current = current.parent
|
|
382
|
+
continue
|
|
383
|
+
candidate = current / part
|
|
384
|
+
if _is_under(candidate, root_path):
|
|
385
|
+
return None
|
|
386
|
+
component = _capture_posix_component(candidate)
|
|
387
|
+
if component is None or len(route) >= MAX_TRUSTED_ROUTE_COMPONENTS:
|
|
388
|
+
return None
|
|
389
|
+
route.append(component)
|
|
390
|
+
if component.link_target is None:
|
|
391
|
+
if not _trusted_posix_component(component, directory=bool(pending)):
|
|
392
|
+
return None
|
|
393
|
+
current = candidate
|
|
394
|
+
continue
|
|
395
|
+
followed_links += 1
|
|
396
|
+
if (
|
|
397
|
+
followed_links > MAX_TRUSTED_LAUNCHER_LINKS
|
|
398
|
+
or component.identity in seen_links
|
|
399
|
+
or not _trusted_posix_component(component, directory=False)
|
|
400
|
+
):
|
|
401
|
+
return None
|
|
402
|
+
seen_links.add(component.identity)
|
|
403
|
+
target = Path(component.link_target)
|
|
404
|
+
remaining = tuple(pending)
|
|
405
|
+
if target.is_absolute():
|
|
406
|
+
target_anchor = Path(target.anchor)
|
|
407
|
+
target_anchor_component = _capture_posix_component(target_anchor)
|
|
408
|
+
if (
|
|
409
|
+
target_anchor_component is None
|
|
410
|
+
or len(route) >= MAX_TRUSTED_ROUTE_COMPONENTS
|
|
411
|
+
or not _trusted_posix_component(target_anchor_component, directory=True)
|
|
412
|
+
):
|
|
413
|
+
return None
|
|
414
|
+
route.append(target_anchor_component)
|
|
415
|
+
current = target_anchor
|
|
416
|
+
target_parts = target.parts[1:]
|
|
417
|
+
else:
|
|
418
|
+
current = candidate.parent
|
|
419
|
+
target_parts = target.parts
|
|
420
|
+
pending = deque((*target_parts, *remaining))
|
|
421
|
+
if _is_under(current, root_path):
|
|
422
|
+
return None
|
|
423
|
+
return current, tuple(route)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _trusted_posix_launcher(path: Path, root: Path) -> TrustedFile | None:
|
|
427
|
+
try:
|
|
428
|
+
lexical = Path(os.path.abspath(path))
|
|
429
|
+
root_path = root.resolve(strict=True)
|
|
430
|
+
except (OSError, RuntimeError):
|
|
431
|
+
return None
|
|
432
|
+
resolved_route = _trusted_posix_route(lexical, root_path)
|
|
433
|
+
if resolved_route is None:
|
|
434
|
+
return None
|
|
435
|
+
current, route = resolved_route
|
|
436
|
+
target_reference = _trusted_file(current, root_path, executable=True)
|
|
437
|
+
if target_reference is None:
|
|
438
|
+
return None
|
|
439
|
+
final_component = route[-1]
|
|
440
|
+
if (
|
|
441
|
+
target_reference.path != current
|
|
442
|
+
or target_reference.identity != final_component.identity
|
|
443
|
+
):
|
|
444
|
+
return None
|
|
445
|
+
return TrustedFile(
|
|
446
|
+
path=target_reference.path,
|
|
447
|
+
identity=target_reference.identity,
|
|
448
|
+
size=target_reference.size,
|
|
449
|
+
mtime_ns=target_reference.mtime_ns,
|
|
450
|
+
ctime_ns=target_reference.ctime_ns,
|
|
451
|
+
sha256=target_reference.sha256,
|
|
452
|
+
launcher_path=lexical,
|
|
453
|
+
launcher_route=route,
|
|
454
|
+
prefix=target_reference.prefix,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _trusted_identity_launcher(path: Path, root: Path) -> TrustedFile | None:
|
|
459
|
+
"""Trust a launcher by its target's identity and content, not by path trust.
|
|
460
|
+
|
|
461
|
+
`_trusted_posix_launcher` requires every component of the route to be
|
|
462
|
+
unwritable by anyone but us, which defends against a hop being swapped
|
|
463
|
+
between validation and exec. That rule is right for a file we are choosing
|
|
464
|
+
to execute. It is the wrong question for the interpreter **we are already
|
|
465
|
+
running**: whoever could substitute `sys.executable` did so before this
|
|
466
|
+
process existed, so the substitution is already decided, and refusing it
|
|
467
|
+
only pushes the caller onto a fallback that launches the *resolved* path --
|
|
468
|
+
a different `sys.prefix`, without the venv's `site-packages`. That is a
|
|
469
|
+
hazard which can still happen, traded for one which cannot.
|
|
470
|
+
|
|
471
|
+
Measured on a GitHub hosted runner: `/opt` through `bin/python3.12` are all
|
|
472
|
+
mode 0777, so all seven components are refused while `_trusted_file` accepts
|
|
473
|
+
the very same binary. This closes that gap deliberately rather than by
|
|
474
|
+
accident.
|
|
475
|
+
|
|
476
|
+
What is NOT relaxed: the target is still pinned by `_trusted_file` (identity,
|
|
477
|
+
size, times, full digest, `O_NOFOLLOW`, `nlink == 1`), and a launcher inside
|
|
478
|
+
the selected repository is still refused, so this cannot widen what a
|
|
479
|
+
repository can aim us at.
|
|
480
|
+
"""
|
|
481
|
+
if os.name == "nt":
|
|
482
|
+
return None
|
|
483
|
+
try:
|
|
484
|
+
lexical = Path(os.path.abspath(path))
|
|
485
|
+
root_path = root.resolve(strict=True)
|
|
486
|
+
target = lexical.resolve(strict=True)
|
|
487
|
+
except (OSError, RuntimeError):
|
|
488
|
+
return None
|
|
489
|
+
if not lexical.is_absolute() or _is_under(lexical, root_path):
|
|
490
|
+
return None
|
|
491
|
+
reference = _trusted_file(target, root_path, executable=True)
|
|
492
|
+
if reference is None or reference.path != target:
|
|
493
|
+
return None
|
|
494
|
+
return TrustedFile(
|
|
495
|
+
path=reference.path,
|
|
496
|
+
identity=reference.identity,
|
|
497
|
+
size=reference.size,
|
|
498
|
+
mtime_ns=reference.mtime_ns,
|
|
499
|
+
ctime_ns=reference.ctime_ns,
|
|
500
|
+
sha256=reference.sha256,
|
|
501
|
+
launcher_path=lexical,
|
|
502
|
+
launcher_identity_only=True,
|
|
503
|
+
prefix=reference.prefix,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _trusted_file(path: Path, root: Path, *, executable: bool) -> TrustedFile | None:
|
|
508
|
+
if not path.is_absolute():
|
|
509
|
+
return None
|
|
510
|
+
try:
|
|
511
|
+
lexical = path.absolute()
|
|
512
|
+
if _path_has_symlink(lexical):
|
|
513
|
+
return None
|
|
514
|
+
canonical = lexical.resolve(strict=True)
|
|
515
|
+
root_path = root.resolve(strict=True)
|
|
516
|
+
initial = canonical.lstat()
|
|
517
|
+
except (OSError, RuntimeError):
|
|
518
|
+
return None
|
|
519
|
+
if (
|
|
520
|
+
_is_under(canonical, root_path)
|
|
521
|
+
or not stat.S_ISREG(initial.st_mode)
|
|
522
|
+
or _is_reparse(initial)
|
|
523
|
+
or initial.st_size > MAX_TRUSTED_FILE_BYTES
|
|
524
|
+
):
|
|
525
|
+
return None
|
|
526
|
+
if executable:
|
|
527
|
+
if os.name == "nt" and canonical.suffix.lower() not in {".exe", ".com"}:
|
|
528
|
+
return None
|
|
529
|
+
if os.name != "nt" and not os.access(canonical, os.X_OK):
|
|
530
|
+
return None
|
|
531
|
+
descriptor = -1
|
|
532
|
+
try:
|
|
533
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
534
|
+
descriptor = os.open(canonical, flags)
|
|
535
|
+
before = os.fstat(descriptor)
|
|
536
|
+
if (
|
|
537
|
+
not stat.S_ISREG(before.st_mode)
|
|
538
|
+
or getattr(before, "st_nlink", 1) != 1
|
|
539
|
+
or before.st_size > MAX_TRUSTED_FILE_BYTES
|
|
540
|
+
):
|
|
541
|
+
return None
|
|
542
|
+
digest = hashlib.sha256()
|
|
543
|
+
prefix = bytearray()
|
|
544
|
+
total_read = 0
|
|
545
|
+
while chunk := os.read(descriptor, 64 * 1024):
|
|
546
|
+
total_read += len(chunk)
|
|
547
|
+
if total_read > MAX_TRUSTED_FILE_BYTES:
|
|
548
|
+
return None
|
|
549
|
+
if len(prefix) < MAX_TRUSTED_EXECUTABLE_PREFIX_BYTES:
|
|
550
|
+
remaining = MAX_TRUSTED_EXECUTABLE_PREFIX_BYTES - len(prefix)
|
|
551
|
+
prefix.extend(chunk[:remaining])
|
|
552
|
+
digest.update(chunk)
|
|
553
|
+
after = os.fstat(descriptor)
|
|
554
|
+
except OSError:
|
|
555
|
+
return None
|
|
556
|
+
finally:
|
|
557
|
+
if descriptor >= 0:
|
|
558
|
+
os.close(descriptor)
|
|
559
|
+
stable_fields = (
|
|
560
|
+
"st_dev",
|
|
561
|
+
"st_ino",
|
|
562
|
+
"st_size",
|
|
563
|
+
"st_mtime_ns",
|
|
564
|
+
"st_nlink",
|
|
565
|
+
)
|
|
566
|
+
if os.name != "nt":
|
|
567
|
+
stable_fields += ("st_ctime_ns",)
|
|
568
|
+
if total_read != before.st_size or any(
|
|
569
|
+
getattr(initial, field, 1) != getattr(before, field, 1) for field in stable_fields
|
|
570
|
+
) or any(
|
|
571
|
+
getattr(before, field, 1) != getattr(after, field, 1) for field in stable_fields
|
|
572
|
+
):
|
|
573
|
+
return None
|
|
574
|
+
return TrustedFile(
|
|
575
|
+
path=canonical,
|
|
576
|
+
identity=(before.st_dev, before.st_ino),
|
|
577
|
+
size=before.st_size,
|
|
578
|
+
mtime_ns=before.st_mtime_ns,
|
|
579
|
+
ctime_ns=None if os.name == "nt" else before.st_ctime_ns,
|
|
580
|
+
sha256=digest.hexdigest(),
|
|
581
|
+
prefix=bytes(prefix),
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def revalidate_trusted_file(reference: TrustedFile, root: Path, executable: bool) -> bool:
|
|
586
|
+
if reference.launcher_path is not None:
|
|
587
|
+
if os.name == "nt" or not executable:
|
|
588
|
+
return False
|
|
589
|
+
# Revalidate the way the reference was BUILT. Re-running the route check
|
|
590
|
+
# over an identity-anchored launcher would refuse it every time, turning
|
|
591
|
+
# every cleanup on a world-writable toolchain into `cleanup_failed`.
|
|
592
|
+
if reference.launcher_identity_only:
|
|
593
|
+
current = _trusted_identity_launcher(reference.launcher_path, root)
|
|
594
|
+
else:
|
|
595
|
+
current = _trusted_posix_launcher(reference.launcher_path, root)
|
|
596
|
+
else:
|
|
597
|
+
current = _trusted_file(reference.path, root, executable=executable)
|
|
598
|
+
return current == reference
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def resolve_trusted_file(
|
|
602
|
+
path: Path, root: Path, *, executable: bool, follow_launcher: bool = False
|
|
603
|
+
) -> TrustedFile | None:
|
|
604
|
+
"""Resolve one caller-selected external file into an immutable identity reference.
|
|
605
|
+
|
|
606
|
+
``follow_launcher`` admits a POSIX symlink route to the file: trust is still
|
|
607
|
+
anchored in the resolved target, but the supplied name is recorded as
|
|
608
|
+
``launcher_path`` so the caller can launch what it was given rather than
|
|
609
|
+
what the name points at (see ``TrustedFile.command_path``). Plain resolution
|
|
610
|
+
cannot express that -- it rejects any path crossing a symlink outright.
|
|
611
|
+
|
|
612
|
+
The flag is deliberately ignored on Windows and for non-executables, because
|
|
613
|
+
``revalidate_trusted_file`` refuses a launcher reference in both cases. A
|
|
614
|
+
launcher reference produced there would pass resolution and then fail closed
|
|
615
|
+
on its first revalidation, with nothing naming the cause.
|
|
616
|
+
"""
|
|
617
|
+
if follow_launcher and executable and os.name != "nt":
|
|
618
|
+
return _trusted_posix_launcher(path, root)
|
|
619
|
+
return _trusted_file(path, root, executable=executable)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _path_entries(path_source: str | Iterable[str | Path]) -> tuple[Path, ...]:
|
|
623
|
+
raw_entries = path_source.split(os.pathsep) if isinstance(path_source, str) else path_source
|
|
624
|
+
entries: list[Path] = []
|
|
625
|
+
for raw in raw_entries:
|
|
626
|
+
entry = Path(raw)
|
|
627
|
+
if not entry.is_absolute():
|
|
628
|
+
continue
|
|
629
|
+
try:
|
|
630
|
+
if _path_has_symlink(entry.absolute()):
|
|
631
|
+
continue
|
|
632
|
+
canonical = entry.resolve(strict=True)
|
|
633
|
+
except (OSError, RuntimeError):
|
|
634
|
+
continue
|
|
635
|
+
if canonical.is_dir():
|
|
636
|
+
entries.append(canonical)
|
|
637
|
+
return tuple(entries)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _posix_manager_path_entries(
|
|
641
|
+
path_source: str | Iterable[str | Path],
|
|
642
|
+
) -> tuple[Path, ...]:
|
|
643
|
+
raw_entries = path_source.split(os.pathsep) if isinstance(path_source, str) else path_source
|
|
644
|
+
entries: list[Path] = []
|
|
645
|
+
for raw in raw_entries:
|
|
646
|
+
entry = Path(raw)
|
|
647
|
+
if entry.is_absolute():
|
|
648
|
+
entries.append(Path(os.path.abspath(entry)))
|
|
649
|
+
return tuple(entries)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def resolve_trusted_executable(
|
|
653
|
+
name: str,
|
|
654
|
+
root: Path,
|
|
655
|
+
path_source: str | Iterable[str | Path],
|
|
656
|
+
*,
|
|
657
|
+
windows: bool | None = None,
|
|
658
|
+
) -> TrustedFile | None:
|
|
659
|
+
use_windows_rules = os.name == "nt" if windows is None else windows
|
|
660
|
+
supplied = Path(name)
|
|
661
|
+
if supplied.name != name or supplied.is_absolute():
|
|
662
|
+
return None
|
|
663
|
+
if use_windows_rules:
|
|
664
|
+
suffix = supplied.suffix.lower()
|
|
665
|
+
names = (name,) if suffix in {".exe", ".com"} else (f"{name}.exe", f"{name}.com")
|
|
666
|
+
else:
|
|
667
|
+
names = (name,)
|
|
668
|
+
posix_manager = (
|
|
669
|
+
os.name != "nt"
|
|
670
|
+
and not use_windows_rules
|
|
671
|
+
and name in {manager.value for manager in Manager}
|
|
672
|
+
)
|
|
673
|
+
directories = (
|
|
674
|
+
_posix_manager_path_entries(path_source)
|
|
675
|
+
if posix_manager
|
|
676
|
+
else _path_entries(path_source)
|
|
677
|
+
)
|
|
678
|
+
for directory in directories:
|
|
679
|
+
for candidate_name in names:
|
|
680
|
+
candidate = directory / candidate_name
|
|
681
|
+
if posix_manager:
|
|
682
|
+
reference = _trusted_posix_launcher(candidate, root)
|
|
683
|
+
else:
|
|
684
|
+
reference = _trusted_file(candidate, root, executable=not use_windows_rules)
|
|
685
|
+
if reference is not None:
|
|
686
|
+
return reference
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def resolve_windows_npm_prefix(
|
|
691
|
+
root: Path, path_source: str | Iterable[str | Path]
|
|
692
|
+
) -> TrustedCommand | None:
|
|
693
|
+
for directory in _path_entries(path_source):
|
|
694
|
+
node = _trusted_file(directory / "node.exe", root, executable=True)
|
|
695
|
+
cli = _trusted_file(directory / "node_modules" / "npm" / "bin" / "npm-cli.js", root, executable=False)
|
|
696
|
+
if node is not None and cli is not None:
|
|
697
|
+
return TrustedCommand((str(node.path), str(cli.path)), (node, cli))
|
|
698
|
+
return None
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
_NODE_SHEBANGS = frozenset(
|
|
702
|
+
{
|
|
703
|
+
b"#!/usr/bin/env node",
|
|
704
|
+
b"#!/usr/bin/node",
|
|
705
|
+
b"#!/bin/node",
|
|
706
|
+
}
|
|
707
|
+
)
|
|
708
|
+
_POSIX_NATIVE_MAGICS = (
|
|
709
|
+
b"\x7fELF",
|
|
710
|
+
b"\xfe\xed\xfa\xce",
|
|
711
|
+
b"\xce\xfa\xed\xfe",
|
|
712
|
+
b"\xfe\xed\xfa\xcf",
|
|
713
|
+
b"\xcf\xfa\xed\xfe",
|
|
714
|
+
b"\xca\xfe\xba\xbe",
|
|
715
|
+
b"\xbe\xba\xfe\xca",
|
|
716
|
+
b"\xca\xfe\xba\xbf",
|
|
717
|
+
b"\xbf\xba\xfe\xca",
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _posix_manager_execution_kind(reference: TrustedFile) -> str | None:
|
|
722
|
+
prefix = reference.prefix
|
|
723
|
+
if prefix.startswith(b"#!"):
|
|
724
|
+
newline = prefix.find(b"\n")
|
|
725
|
+
if newline < 0:
|
|
726
|
+
return None
|
|
727
|
+
shebang = prefix[:newline]
|
|
728
|
+
if shebang.endswith(b"\r"):
|
|
729
|
+
shebang = shebang[:-1]
|
|
730
|
+
return "node" if shebang in _NODE_SHEBANGS else None
|
|
731
|
+
return "native" if prefix.startswith(_POSIX_NATIVE_MAGICS) else None
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def command_for(
|
|
735
|
+
reference: TrustedFile, node: TrustedFile | None = None
|
|
736
|
+
) -> TrustedCommand | None:
|
|
737
|
+
if os.name != "nt" and reference.launcher_path is not None:
|
|
738
|
+
execution_kind = _posix_manager_execution_kind(reference)
|
|
739
|
+
if execution_kind == "node":
|
|
740
|
+
if node is None or node.launcher_path is not None:
|
|
741
|
+
return None
|
|
742
|
+
return TrustedCommand(
|
|
743
|
+
(str(node.path), str(reference.command_path)), (node, reference)
|
|
744
|
+
)
|
|
745
|
+
if execution_kind != "native":
|
|
746
|
+
return None
|
|
747
|
+
return TrustedCommand((str(reference.command_path),), (reference,))
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _trusted_windows_system_environment() -> dict[str, str]:
|
|
751
|
+
if os.name != "nt":
|
|
752
|
+
return {}
|
|
753
|
+
try:
|
|
754
|
+
import ctypes
|
|
755
|
+
|
|
756
|
+
buffer = ctypes.create_unicode_buffer(32768)
|
|
757
|
+
length = ctypes.windll.kernel32.GetWindowsDirectoryW(buffer, len(buffer))
|
|
758
|
+
if length <= 0 or length >= len(buffer):
|
|
759
|
+
raise OSError("windows_directory_unavailable")
|
|
760
|
+
windows = Path(buffer.value).resolve(strict=True)
|
|
761
|
+
system32 = (windows / "System32").resolve(strict=True)
|
|
762
|
+
command = (system32 / "cmd.exe").resolve(strict=True)
|
|
763
|
+
if not system32.is_dir() or not command.is_file():
|
|
764
|
+
raise OSError("windows_system_paths_invalid")
|
|
765
|
+
except (AttributeError, OSError, RuntimeError):
|
|
766
|
+
raise ValueError("windows_system_paths_unavailable") from None
|
|
767
|
+
return {
|
|
768
|
+
"SYSTEMROOT": str(windows),
|
|
769
|
+
"WINDIR": str(windows),
|
|
770
|
+
"COMSPEC": str(command),
|
|
771
|
+
"PATHEXT": ".COM;.EXE",
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def build_install_environment(
|
|
776
|
+
manager: Manager,
|
|
777
|
+
isolated_home: Path,
|
|
778
|
+
executable_directories: Iterable[Path],
|
|
779
|
+
registry: str,
|
|
780
|
+
source: Mapping[str, str],
|
|
781
|
+
) -> dict[str, str]:
|
|
782
|
+
base = isolated_home.resolve()
|
|
783
|
+
environment = {name: source[name] for name in _LOCALE_ENVIRONMENT if name in source}
|
|
784
|
+
trusted_directories: list[str] = []
|
|
785
|
+
for directory in executable_directories:
|
|
786
|
+
if not directory.is_absolute():
|
|
787
|
+
continue
|
|
788
|
+
try:
|
|
789
|
+
canonical = directory.resolve(strict=True)
|
|
790
|
+
except OSError:
|
|
791
|
+
continue
|
|
792
|
+
if canonical.is_dir() and str(canonical) not in trusted_directories:
|
|
793
|
+
trusted_directories.append(str(canonical))
|
|
794
|
+
if os.name == "nt":
|
|
795
|
+
trusted_windows = _trusted_windows_system_environment()
|
|
796
|
+
environment.update(trusted_windows)
|
|
797
|
+
trusted_directories.append(str(Path(trusted_windows["SYSTEMROOT"]) / "System32"))
|
|
798
|
+
else:
|
|
799
|
+
trusted_directories.extend(("/usr/bin", "/bin"))
|
|
800
|
+
environment["PATH"] = os.pathsep.join(trusted_directories)
|
|
801
|
+
environment.update(
|
|
802
|
+
{
|
|
803
|
+
"HOME": str(base / "home"),
|
|
804
|
+
"USERPROFILE": str(base / "home"),
|
|
805
|
+
"XDG_CONFIG_HOME": str(base / "config"),
|
|
806
|
+
"XDG_CACHE_HOME": str(base / "cache"),
|
|
807
|
+
"TEMP": str(base / "tmp"),
|
|
808
|
+
"TMP": str(base / "tmp"),
|
|
809
|
+
"APPDATA": str(base / "appdata"),
|
|
810
|
+
"LOCALAPPDATA": str(base / "localappdata"),
|
|
811
|
+
}
|
|
812
|
+
)
|
|
813
|
+
selected = Manager(manager)
|
|
814
|
+
if selected in {Manager.NPM, Manager.PNPM}:
|
|
815
|
+
environment.update(
|
|
816
|
+
{
|
|
817
|
+
"NPM_CONFIG_USERCONFIG": str(base / "npm-config" / "user.npmrc"),
|
|
818
|
+
"NPM_CONFIG_GLOBALCONFIG": str(base / "npm-config" / "global.npmrc"),
|
|
819
|
+
"NPM_CONFIG_CACHE": str(base / "npm-cache"),
|
|
820
|
+
"NPM_CONFIG_PREFIX": str(base / "npm-prefix"),
|
|
821
|
+
"NPM_CONFIG_REGISTRY": registry,
|
|
822
|
+
"NPM_CONFIG_IGNORE_SCRIPTS": "true",
|
|
823
|
+
"NPM_CONFIG_AUDIT": "false",
|
|
824
|
+
"NPM_CONFIG_FUND": "false",
|
|
825
|
+
"npm_config_registry": registry,
|
|
826
|
+
"npm_config_ignore_scripts": "true",
|
|
827
|
+
}
|
|
828
|
+
)
|
|
829
|
+
if selected is Manager.PNPM:
|
|
830
|
+
environment.update(
|
|
831
|
+
{
|
|
832
|
+
"PNPM_HOME": str(base / "pnpm-home"),
|
|
833
|
+
"PNPM_STORE_DIR": str(base / "pnpm-store"),
|
|
834
|
+
"PNPM_CONFIG_DIR": str(base / "pnpm-config"),
|
|
835
|
+
"npm_config_store_dir": str(base / "pnpm-store"),
|
|
836
|
+
}
|
|
837
|
+
)
|
|
838
|
+
elif selected is Manager.YARN:
|
|
839
|
+
environment.update(
|
|
840
|
+
{
|
|
841
|
+
"YARN_NPM_REGISTRY_SERVER": registry,
|
|
842
|
+
"YARN_ENABLE_SCRIPTS": "false",
|
|
843
|
+
"YARN_ENABLE_TELEMETRY": "0",
|
|
844
|
+
"YARN_GLOBAL_FOLDER": str(base / "yarn-global"),
|
|
845
|
+
}
|
|
846
|
+
)
|
|
847
|
+
else:
|
|
848
|
+
environment["BUN_INSTALL_CACHE_DIR"] = str(base / "bun-cache")
|
|
849
|
+
return environment
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _prepare_isolated_install_home(root: Path, isolated_home: Path, manager: Manager) -> bool:
|
|
853
|
+
if not isolated_home.is_absolute():
|
|
854
|
+
return False
|
|
855
|
+
try:
|
|
856
|
+
canonical_root = root.resolve(strict=True)
|
|
857
|
+
lexical_base = isolated_home.absolute()
|
|
858
|
+
if _is_under(lexical_base, canonical_root):
|
|
859
|
+
return False
|
|
860
|
+
parent = lexical_base.parent.resolve(strict=True)
|
|
861
|
+
if _path_has_symlink(parent) or _is_under(parent, canonical_root):
|
|
862
|
+
return False
|
|
863
|
+
if lexical_base.exists() or lexical_base.is_symlink():
|
|
864
|
+
details = lexical_base.lstat()
|
|
865
|
+
if stat.S_ISLNK(details.st_mode) or _is_reparse(details) or not stat.S_ISDIR(details.st_mode):
|
|
866
|
+
return False
|
|
867
|
+
else:
|
|
868
|
+
lexical_base.mkdir(mode=0o700)
|
|
869
|
+
canonical_base = lexical_base.resolve(strict=True)
|
|
870
|
+
if canonical_base != lexical_base or _is_under(canonical_base, canonical_root):
|
|
871
|
+
return False
|
|
872
|
+
directory_names = {
|
|
873
|
+
"home",
|
|
874
|
+
"config",
|
|
875
|
+
"cache",
|
|
876
|
+
"tmp",
|
|
877
|
+
"appdata",
|
|
878
|
+
"localappdata",
|
|
879
|
+
}
|
|
880
|
+
selected = Manager(manager)
|
|
881
|
+
if selected in {Manager.NPM, Manager.PNPM}:
|
|
882
|
+
directory_names.update({"npm-config", "npm-cache", "npm-prefix"})
|
|
883
|
+
if selected is Manager.PNPM:
|
|
884
|
+
directory_names.update({"pnpm-home", "pnpm-store", "pnpm-config"})
|
|
885
|
+
elif selected is Manager.YARN:
|
|
886
|
+
directory_names.add("yarn-global")
|
|
887
|
+
elif selected is Manager.BUN:
|
|
888
|
+
directory_names.add("bun-cache")
|
|
889
|
+
for name in directory_names:
|
|
890
|
+
directory = canonical_base / name
|
|
891
|
+
directory.mkdir(mode=0o700, exist_ok=True)
|
|
892
|
+
details = directory.lstat()
|
|
893
|
+
if (
|
|
894
|
+
directory.resolve(strict=True) != directory
|
|
895
|
+
or not _is_under(directory, canonical_base)
|
|
896
|
+
or stat.S_ISLNK(details.st_mode)
|
|
897
|
+
or _is_reparse(details)
|
|
898
|
+
or not stat.S_ISDIR(details.st_mode)
|
|
899
|
+
):
|
|
900
|
+
return False
|
|
901
|
+
if selected in {Manager.NPM, Manager.PNPM}:
|
|
902
|
+
for name in ("user.npmrc", "global.npmrc"):
|
|
903
|
+
config = canonical_base / "npm-config" / name
|
|
904
|
+
if config.exists() or config.is_symlink():
|
|
905
|
+
existing = config.lstat()
|
|
906
|
+
if (
|
|
907
|
+
stat.S_ISLNK(existing.st_mode)
|
|
908
|
+
or _is_reparse(existing)
|
|
909
|
+
or not stat.S_ISREG(existing.st_mode)
|
|
910
|
+
or getattr(existing, "st_nlink", 1) != 1
|
|
911
|
+
):
|
|
912
|
+
return False
|
|
913
|
+
flags = (
|
|
914
|
+
os.O_RDWR
|
|
915
|
+
| os.O_CREAT
|
|
916
|
+
| getattr(os, "O_BINARY", 0)
|
|
917
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
918
|
+
)
|
|
919
|
+
descriptor = os.open(config, flags, 0o600)
|
|
920
|
+
try:
|
|
921
|
+
details = os.fstat(descriptor)
|
|
922
|
+
if not stat.S_ISREG(details.st_mode) or getattr(details, "st_nlink", 1) != 1:
|
|
923
|
+
return False
|
|
924
|
+
os.ftruncate(descriptor, 0)
|
|
925
|
+
finally:
|
|
926
|
+
os.close(descriptor)
|
|
927
|
+
return True
|
|
928
|
+
except (OSError, RuntimeError, ValueError):
|
|
929
|
+
return False
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def snapshot_control_file(root: Path, relative_path: str) -> FileSnapshot:
|
|
933
|
+
if (
|
|
934
|
+
not relative_path
|
|
935
|
+
or relative_path in {".", ".."}
|
|
936
|
+
or "/" in relative_path
|
|
937
|
+
or "\\" in relative_path
|
|
938
|
+
or Path(relative_path).is_absolute()
|
|
939
|
+
):
|
|
940
|
+
raise ValueError("control_file_invalid")
|
|
941
|
+
try:
|
|
942
|
+
canonical_root = root.resolve(strict=True)
|
|
943
|
+
path = canonical_root / relative_path
|
|
944
|
+
initial = path.lstat()
|
|
945
|
+
if stat.S_ISLNK(initial.st_mode) or _is_reparse(initial) or not stat.S_ISREG(initial.st_mode):
|
|
946
|
+
raise ValueError("control_file_invalid")
|
|
947
|
+
canonical = path.resolve(strict=True)
|
|
948
|
+
if not _is_under(canonical, canonical_root) or canonical != path:
|
|
949
|
+
raise ValueError("control_file_invalid")
|
|
950
|
+
if initial.st_size > MAX_CONTROL_FILE_BYTES:
|
|
951
|
+
raise ValueError("control_file_invalid")
|
|
952
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
953
|
+
descriptor = os.open(path, flags)
|
|
954
|
+
try:
|
|
955
|
+
before = os.fstat(descriptor)
|
|
956
|
+
content = os.read(descriptor, MAX_CONTROL_FILE_BYTES + 1)
|
|
957
|
+
after = os.fstat(descriptor)
|
|
958
|
+
finally:
|
|
959
|
+
os.close(descriptor)
|
|
960
|
+
except ValueError:
|
|
961
|
+
raise
|
|
962
|
+
except (OSError, RuntimeError):
|
|
963
|
+
raise ValueError("control_file_invalid") from None
|
|
964
|
+
stable_fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns")
|
|
965
|
+
if (
|
|
966
|
+
len(content) > MAX_CONTROL_FILE_BYTES
|
|
967
|
+
or len(content) != before.st_size
|
|
968
|
+
or any(getattr(initial, field) != getattr(before, field) for field in stable_fields)
|
|
969
|
+
or any(getattr(before, field) != getattr(after, field) for field in stable_fields)
|
|
970
|
+
):
|
|
971
|
+
raise ValueError("control_file_changed")
|
|
972
|
+
return FileSnapshot(relative_path, (before.st_dev, before.st_ino), hashlib.sha256(content).hexdigest())
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _dependency_spec_is_safe(specification: str) -> bool:
|
|
976
|
+
value = specification.strip()
|
|
977
|
+
lower = value.lower()
|
|
978
|
+
if not value or value != specification:
|
|
979
|
+
return False
|
|
980
|
+
if lower.startswith(("file:", "link:", "git:", "git+", "ssh:", "http:", "https:", "workspace:")):
|
|
981
|
+
return False
|
|
982
|
+
if lower.endswith((".tgz", ".tar.gz")):
|
|
983
|
+
return False
|
|
984
|
+
if lower.startswith("git@") or any(separator in value for separator in ("/", "\\", ":")):
|
|
985
|
+
return False
|
|
986
|
+
if value.startswith("."):
|
|
987
|
+
return False
|
|
988
|
+
return True
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _is_canonical_registry_url(value: str) -> bool:
|
|
992
|
+
try:
|
|
993
|
+
parsed = urlsplit(value)
|
|
994
|
+
except ValueError:
|
|
995
|
+
return False
|
|
996
|
+
return (
|
|
997
|
+
parsed.scheme == "https"
|
|
998
|
+
and parsed.netloc == "registry.npmjs.org"
|
|
999
|
+
and parsed.path.startswith("/")
|
|
1000
|
+
and not parsed.query
|
|
1001
|
+
and not parsed.fragment
|
|
1002
|
+
and parsed.username is None
|
|
1003
|
+
and parsed.password is None
|
|
1004
|
+
and parsed.port is None
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _json_source_fields_are_trusted(value: Any) -> bool:
|
|
1009
|
+
if isinstance(value, list):
|
|
1010
|
+
return all(_json_source_fields_are_trusted(item) for item in value)
|
|
1011
|
+
if not isinstance(value, dict):
|
|
1012
|
+
return True
|
|
1013
|
+
for key, item in value.items():
|
|
1014
|
+
lowered = key.lower()
|
|
1015
|
+
if lowered in _SOURCE_FIELD_KEYS:
|
|
1016
|
+
if not isinstance(item, str) or not _is_canonical_registry_url(item):
|
|
1017
|
+
return False
|
|
1018
|
+
elif lowered == "resolution" and isinstance(item, dict):
|
|
1019
|
+
if any(nested.lower() not in {"integrity", "tarball"} for nested in item):
|
|
1020
|
+
return False
|
|
1021
|
+
if not _json_source_fields_are_trusted(item):
|
|
1022
|
+
return False
|
|
1023
|
+
return True
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _text_source_fields_are_trusted(text: str) -> bool:
|
|
1027
|
+
for line in text.splitlines():
|
|
1028
|
+
content = line.strip()
|
|
1029
|
+
mapping = re.fullmatch(
|
|
1030
|
+
r"(?P<key>\"[^\"]*\"|'(?:[^']|'')*'|[A-Za-z][A-Za-z0-9_-]*):\s+(?P<value>.+)",
|
|
1031
|
+
content,
|
|
1032
|
+
)
|
|
1033
|
+
if mapping is not None:
|
|
1034
|
+
key = _normalize_yaml_mapping_key(mapping.group("key"))
|
|
1035
|
+
if key is None:
|
|
1036
|
+
return False
|
|
1037
|
+
if key.lower() not in _SOURCE_FIELD_KEYS:
|
|
1038
|
+
continue
|
|
1039
|
+
source = mapping.group("value").strip()
|
|
1040
|
+
else:
|
|
1041
|
+
classic = re.fullmatch(r"(?i)(resolved|tarball|fetch|source|path|url)\s+(.+)", content)
|
|
1042
|
+
if classic is None:
|
|
1043
|
+
continue
|
|
1044
|
+
source = classic.group(2).strip()
|
|
1045
|
+
if source.startswith(("'", '"')) and source.endswith(source[0]) and len(source) >= 2:
|
|
1046
|
+
source = source[1:-1]
|
|
1047
|
+
if not _is_canonical_registry_url(source):
|
|
1048
|
+
return False
|
|
1049
|
+
return True
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _normalize_yaml_mapping_key(value: str) -> str | None:
|
|
1053
|
+
if value.startswith('"'):
|
|
1054
|
+
if len(value) < 2 or not value.endswith('"') or '"' in value[1:-1]:
|
|
1055
|
+
return None
|
|
1056
|
+
return value[1:-1]
|
|
1057
|
+
if value.startswith("'"):
|
|
1058
|
+
if len(value) < 2 or not value.endswith("'"):
|
|
1059
|
+
return None
|
|
1060
|
+
inner = value[1:-1]
|
|
1061
|
+
result: list[str] = []
|
|
1062
|
+
index = 0
|
|
1063
|
+
while index < len(inner):
|
|
1064
|
+
if inner[index] == "'":
|
|
1065
|
+
if index + 1 >= len(inner) or inner[index + 1] != "'":
|
|
1066
|
+
return None
|
|
1067
|
+
result.append("'")
|
|
1068
|
+
index += 2
|
|
1069
|
+
else:
|
|
1070
|
+
result.append(inner[index])
|
|
1071
|
+
index += 1
|
|
1072
|
+
return "".join(result)
|
|
1073
|
+
return value if re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", value) else None
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _normalize_text_lockfile_escapes(text: str) -> str | None:
|
|
1077
|
+
"""Decode a bounded subset of JSON escapes exactly once, rejecting ambiguity."""
|
|
1078
|
+
output: list[str] = []
|
|
1079
|
+
index = 0
|
|
1080
|
+
while index < len(text):
|
|
1081
|
+
character = text[index]
|
|
1082
|
+
if character != "\\":
|
|
1083
|
+
output.append(character)
|
|
1084
|
+
index += 1
|
|
1085
|
+
continue
|
|
1086
|
+
if index + 1 >= len(text):
|
|
1087
|
+
return None
|
|
1088
|
+
escape = text[index + 1]
|
|
1089
|
+
if escape in {"/", "\\"}:
|
|
1090
|
+
output.append(escape)
|
|
1091
|
+
index += 2
|
|
1092
|
+
continue
|
|
1093
|
+
if escape not in {"u", "U"} or index + 6 > len(text):
|
|
1094
|
+
return None
|
|
1095
|
+
digits = text[index + 2 : index + 6]
|
|
1096
|
+
if re.fullmatch(r"[0-9A-Fa-f]{4}", digits) is None:
|
|
1097
|
+
return None
|
|
1098
|
+
decoded = chr(int(digits, 16))
|
|
1099
|
+
if ord(decoded) < 32 or 0xD800 <= ord(decoded) <= 0xDFFF:
|
|
1100
|
+
return None
|
|
1101
|
+
output.append(decoded)
|
|
1102
|
+
index += 6
|
|
1103
|
+
normalized = "".join(output)
|
|
1104
|
+
if re.search(r"\\[uU][0-9A-Fa-f]{4}", normalized):
|
|
1105
|
+
return None
|
|
1106
|
+
return normalized
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _line_has_balanced_structures(content: str) -> bool:
|
|
1110
|
+
quote: str | None = None
|
|
1111
|
+
brackets: list[str] = []
|
|
1112
|
+
pairs = {")": "(", "]": "[", "}": "{"}
|
|
1113
|
+
for character in content:
|
|
1114
|
+
if quote is not None:
|
|
1115
|
+
if character == quote:
|
|
1116
|
+
quote = None
|
|
1117
|
+
continue
|
|
1118
|
+
if character in {"'", '"'}:
|
|
1119
|
+
quote = character
|
|
1120
|
+
elif character in "([{":
|
|
1121
|
+
brackets.append(character)
|
|
1122
|
+
elif character in ")]}":
|
|
1123
|
+
if not brackets or brackets.pop() != pairs[character]:
|
|
1124
|
+
return False
|
|
1125
|
+
return quote is None and not brackets
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _mapping_line(content: str) -> tuple[bool, bool]:
|
|
1129
|
+
match = _MAPPING_LINE_RE.fullmatch(content)
|
|
1130
|
+
if match is None:
|
|
1131
|
+
return False, False
|
|
1132
|
+
value = match.group(1)
|
|
1133
|
+
if value is not None and not _yaml_scalar_is_valid(value):
|
|
1134
|
+
return False, False
|
|
1135
|
+
return True, value is None
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _yaml_scalar_remainder_is_valid(remainder: str) -> bool:
|
|
1139
|
+
return not remainder or (remainder[0].isspace() and remainder.lstrip().startswith("#"))
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _reject_json_constant(_constant: str) -> None:
|
|
1143
|
+
raise ValueError("invalid_json_constant")
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
1147
|
+
result: dict[str, Any] = {}
|
|
1148
|
+
for key, value in pairs:
|
|
1149
|
+
if key in result:
|
|
1150
|
+
raise ValueError("duplicate_json_key")
|
|
1151
|
+
result[key] = value
|
|
1152
|
+
return result
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _parse_bounded_json_int(value: str) -> int:
|
|
1156
|
+
if len(value.lstrip("-")) > 1024:
|
|
1157
|
+
raise ValueError("json_integer_too_long")
|
|
1158
|
+
return int(value)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _parse_finite_json_float(value: str) -> float:
|
|
1162
|
+
parsed = float(value)
|
|
1163
|
+
if not math.isfinite(parsed):
|
|
1164
|
+
raise ValueError("json_float_not_finite")
|
|
1165
|
+
return parsed
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _strict_json_loads(value: bytes | str) -> Any:
|
|
1169
|
+
return json.loads(
|
|
1170
|
+
value,
|
|
1171
|
+
parse_constant=_reject_json_constant,
|
|
1172
|
+
object_pairs_hook=_reject_duplicate_json_keys,
|
|
1173
|
+
parse_int=_parse_bounded_json_int,
|
|
1174
|
+
parse_float=_parse_finite_json_float,
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _yaml_scalar_is_valid(value: str) -> bool:
|
|
1179
|
+
if not value:
|
|
1180
|
+
return False
|
|
1181
|
+
if value[0] in {"'", '"'}:
|
|
1182
|
+
closing = value.find(value[0], 1)
|
|
1183
|
+
return closing >= 1 and _yaml_scalar_remainder_is_valid(value[closing + 1 :])
|
|
1184
|
+
if value[0] in "[{":
|
|
1185
|
+
pairs = {"]": "[", "}": "{"}
|
|
1186
|
+
stack = [value[0]]
|
|
1187
|
+
quote: str | None = None
|
|
1188
|
+
for index, character in enumerate(value[1:], start=1):
|
|
1189
|
+
if quote is not None:
|
|
1190
|
+
if character == quote:
|
|
1191
|
+
quote = None
|
|
1192
|
+
continue
|
|
1193
|
+
if character in {"'", '"'}:
|
|
1194
|
+
quote = character
|
|
1195
|
+
elif character in "[{":
|
|
1196
|
+
stack.append(character)
|
|
1197
|
+
elif character in "]}":
|
|
1198
|
+
if not stack or stack.pop() != pairs[character]:
|
|
1199
|
+
return False
|
|
1200
|
+
if not stack:
|
|
1201
|
+
flow_text = value[: index + 1]
|
|
1202
|
+
try:
|
|
1203
|
+
parsed_flow = _strict_json_loads(flow_text)
|
|
1204
|
+
except (ValueError, RecursionError):
|
|
1205
|
+
return False
|
|
1206
|
+
expected_type = dict if value[0] == "{" else list
|
|
1207
|
+
return (
|
|
1208
|
+
isinstance(parsed_flow, expected_type)
|
|
1209
|
+
and _json_source_fields_are_trusted(parsed_flow)
|
|
1210
|
+
and _yaml_scalar_remainder_is_valid(value[index + 1 :])
|
|
1211
|
+
)
|
|
1212
|
+
return False
|
|
1213
|
+
plain_value = value
|
|
1214
|
+
for index, character in enumerate(value):
|
|
1215
|
+
if character == "#" and (index == 0 or value[index - 1].isspace()):
|
|
1216
|
+
plain_value = value[:index].rstrip()
|
|
1217
|
+
break
|
|
1218
|
+
if (
|
|
1219
|
+
not plain_value
|
|
1220
|
+
or plain_value.endswith(":")
|
|
1221
|
+
or any(character in plain_value for character in "[]{}")
|
|
1222
|
+
):
|
|
1223
|
+
return False
|
|
1224
|
+
return not any(
|
|
1225
|
+
character == ":" and index + 1 < len(plain_value) and plain_value[index + 1].isspace()
|
|
1226
|
+
for index, character in enumerate(plain_value)
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _validate_mapping_lockfile(lines: list[str], *, berry: bool) -> bool:
|
|
1231
|
+
previous_indent = 0
|
|
1232
|
+
previous_opens = False
|
|
1233
|
+
saw_significant = False
|
|
1234
|
+
saw_pnpm_section = False
|
|
1235
|
+
saw_metadata_version = False
|
|
1236
|
+
saw_package_entry = False
|
|
1237
|
+
saw_package_version = False
|
|
1238
|
+
for line in lines:
|
|
1239
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
1240
|
+
continue
|
|
1241
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
1242
|
+
content = line[indent:].rstrip()
|
|
1243
|
+
if indent % 2 or indent > 40 or not _line_has_balanced_structures(content):
|
|
1244
|
+
return False
|
|
1245
|
+
if saw_significant and indent > previous_indent:
|
|
1246
|
+
if indent != previous_indent + 2 or not previous_opens:
|
|
1247
|
+
return False
|
|
1248
|
+
valid, opens = _mapping_line(content)
|
|
1249
|
+
if not valid:
|
|
1250
|
+
return False
|
|
1251
|
+
if indent == 0 and content.split(":", 1)[0] in {"importers", "packages", "snapshots"}:
|
|
1252
|
+
saw_pnpm_section = True
|
|
1253
|
+
if berry:
|
|
1254
|
+
if indent == 2 and re.fullmatch(r"version:\s*\d+(?:\.\d+)*", content):
|
|
1255
|
+
if not saw_package_entry:
|
|
1256
|
+
saw_metadata_version = True
|
|
1257
|
+
else:
|
|
1258
|
+
saw_package_version = True
|
|
1259
|
+
if indent == 0 and content != "__metadata:":
|
|
1260
|
+
saw_package_entry = True
|
|
1261
|
+
previous_indent = indent
|
|
1262
|
+
previous_opens = opens
|
|
1263
|
+
saw_significant = True
|
|
1264
|
+
if berry:
|
|
1265
|
+
return saw_metadata_version and saw_package_entry and saw_package_version
|
|
1266
|
+
return saw_pnpm_section
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _validate_yarn_classic(lines: list[str]) -> bool:
|
|
1270
|
+
previous_indent = 0
|
|
1271
|
+
previous_opens = False
|
|
1272
|
+
saw_significant = False
|
|
1273
|
+
saw_entry = False
|
|
1274
|
+
current_entry_has_version = False
|
|
1275
|
+
for line in lines[1:]:
|
|
1276
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
1277
|
+
continue
|
|
1278
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
1279
|
+
content = line[indent:].rstrip()
|
|
1280
|
+
if indent not in {0, 2, 4} or not _line_has_balanced_structures(content):
|
|
1281
|
+
return False
|
|
1282
|
+
if saw_significant and indent > previous_indent:
|
|
1283
|
+
if indent != previous_indent + 2 or not previous_opens:
|
|
1284
|
+
return False
|
|
1285
|
+
opens = False
|
|
1286
|
+
if indent == 0:
|
|
1287
|
+
if saw_entry and not current_entry_has_version:
|
|
1288
|
+
return False
|
|
1289
|
+
valid, opens = _mapping_line(content)
|
|
1290
|
+
if not valid or not opens:
|
|
1291
|
+
return False
|
|
1292
|
+
saw_entry = True
|
|
1293
|
+
current_entry_has_version = False
|
|
1294
|
+
elif indent == 2:
|
|
1295
|
+
block_match = re.fullmatch(r"(?:dependencies|optionalDependencies|peerDependencies):", content)
|
|
1296
|
+
field_match = re.fullmatch(r"(?:version|resolved|integrity|uid)\s+\S.*", content)
|
|
1297
|
+
if block_match is None and field_match is None:
|
|
1298
|
+
return False
|
|
1299
|
+
opens = block_match is not None
|
|
1300
|
+
if content.startswith("version "):
|
|
1301
|
+
current_entry_has_version = True
|
|
1302
|
+
elif re.fullmatch(r"(?:\"[^\"]+\"|'[^']+'|[^\s:]+)\s+\S.*", content) is None:
|
|
1303
|
+
return False
|
|
1304
|
+
previous_indent = indent
|
|
1305
|
+
previous_opens = opens
|
|
1306
|
+
saw_significant = True
|
|
1307
|
+
return saw_entry and current_entry_has_version
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _normalized_lockfile_text(lockfile_bytes: bytes) -> str | None:
|
|
1311
|
+
"""Return decoded text only for a conservatively recognized lockfile format."""
|
|
1312
|
+
try:
|
|
1313
|
+
text = lockfile_bytes.decode("utf-8")
|
|
1314
|
+
except UnicodeDecodeError:
|
|
1315
|
+
return None
|
|
1316
|
+
if any(ord(character) < 32 and character not in "\r\n" for character in text):
|
|
1317
|
+
return None
|
|
1318
|
+
text = text.replace("\r\n", "\n")
|
|
1319
|
+
if "\r" in text:
|
|
1320
|
+
return None
|
|
1321
|
+
stripped = text.lstrip("\ufeff \n")
|
|
1322
|
+
if not stripped:
|
|
1323
|
+
return None
|
|
1324
|
+
if stripped.startswith("{"):
|
|
1325
|
+
try:
|
|
1326
|
+
parsed = _strict_json_loads(stripped)
|
|
1327
|
+
except (UnicodeDecodeError, ValueError, RecursionError):
|
|
1328
|
+
return None
|
|
1329
|
+
if not isinstance(parsed, dict):
|
|
1330
|
+
return None
|
|
1331
|
+
if not _json_source_fields_are_trusted(parsed):
|
|
1332
|
+
return None
|
|
1333
|
+
lockfile_version = parsed.get("lockfileVersion")
|
|
1334
|
+
if isinstance(lockfile_version, bool) or not (
|
|
1335
|
+
isinstance(lockfile_version, int)
|
|
1336
|
+
or (isinstance(lockfile_version, str) and re.fullmatch(r"\d+(?:\.\d+)?", lockfile_version))
|
|
1337
|
+
):
|
|
1338
|
+
return None
|
|
1339
|
+
return json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))
|
|
1340
|
+
stripped = _normalize_text_lockfile_escapes(stripped)
|
|
1341
|
+
if stripped is None:
|
|
1342
|
+
return None
|
|
1343
|
+
lines = stripped.splitlines()
|
|
1344
|
+
if any(len(line) > _LOCKFILE_LINE_LIMIT for line in lines):
|
|
1345
|
+
return None
|
|
1346
|
+
if lines[0].startswith("lockfileVersion:"):
|
|
1347
|
+
if not re.fullmatch(r"lockfileVersion:\s*['\"]?\d+(?:\.\d+)?['\"]?\s*", lines[0]):
|
|
1348
|
+
return None
|
|
1349
|
+
return stripped if _validate_mapping_lockfile(lines, berry=False) else None
|
|
1350
|
+
if lines[0].strip() == "# yarn lockfile v1":
|
|
1351
|
+
return stripped if _validate_yarn_classic(lines) else None
|
|
1352
|
+
first_content_lines = [line.strip() for line in lines[:10] if line.strip() and not line.lstrip().startswith("#")]
|
|
1353
|
+
if first_content_lines and first_content_lines[0] == "__metadata:":
|
|
1354
|
+
return stripped if _validate_mapping_lockfile(lines, berry=True) else None
|
|
1355
|
+
return None
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _lockfile_uses_trusted_sources(lockfile_bytes: bytes) -> bool:
|
|
1359
|
+
normalized = _normalized_lockfile_text(lockfile_bytes)
|
|
1360
|
+
if normalized is None:
|
|
1361
|
+
return False
|
|
1362
|
+
normalized = normalized.replace("\\/", "/")
|
|
1363
|
+
lower = normalized.lower()
|
|
1364
|
+
if not _text_source_fields_are_trusted(normalized):
|
|
1365
|
+
return False
|
|
1366
|
+
if any(
|
|
1367
|
+
marker in lower
|
|
1368
|
+
for marker in ("git+", "git://", "git@", "ssh:", "file:", "link:", "local:")
|
|
1369
|
+
):
|
|
1370
|
+
return False
|
|
1371
|
+
if re.search(r"[\"'](?:\.\.?[/\\]|[/\\]|[A-Za-z]:[/\\])", normalized) or re.search(
|
|
1372
|
+
r"(?m)^\s*[A-Za-z][\w-]*:\s*(?:\.\.?[/\\]|[/\\]{1,2}|[A-Za-z]:[/\\])",
|
|
1373
|
+
normalized,
|
|
1374
|
+
):
|
|
1375
|
+
return False
|
|
1376
|
+
uri_matches = tuple(_URI_RE.finditer(normalized))
|
|
1377
|
+
for match in uri_matches:
|
|
1378
|
+
try:
|
|
1379
|
+
parsed = urlsplit(match.group())
|
|
1380
|
+
except ValueError:
|
|
1381
|
+
return False
|
|
1382
|
+
if (
|
|
1383
|
+
match.group(1).lower() != "https"
|
|
1384
|
+
or parsed.scheme.lower() != "https"
|
|
1385
|
+
or parsed.hostname != "registry.npmjs.org"
|
|
1386
|
+
or parsed.username is not None
|
|
1387
|
+
or parsed.password is not None
|
|
1388
|
+
or parsed.port is not None
|
|
1389
|
+
):
|
|
1390
|
+
return False
|
|
1391
|
+
without_valid_url_shapes = _URI_RE.sub("", normalized).lower()
|
|
1392
|
+
if "http:" in without_valid_url_shapes or "https:" in without_valid_url_shapes:
|
|
1393
|
+
return False
|
|
1394
|
+
return True
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def control_files_use_trusted_sources(manifest_bytes: bytes, lockfile_bytes: bytes) -> bool:
|
|
1398
|
+
if len(manifest_bytes) > MAX_CONTROL_FILE_BYTES or len(lockfile_bytes) > MAX_CONTROL_FILE_BYTES:
|
|
1399
|
+
return False
|
|
1400
|
+
try:
|
|
1401
|
+
manifest = _strict_json_loads(manifest_bytes)
|
|
1402
|
+
except (UnicodeDecodeError, ValueError, RecursionError):
|
|
1403
|
+
return False
|
|
1404
|
+
if not isinstance(manifest, dict) or _FORBIDDEN_MANIFEST_FIELDS.intersection(manifest):
|
|
1405
|
+
return False
|
|
1406
|
+
for field in _DEPENDENCY_FIELDS:
|
|
1407
|
+
dependencies = manifest.get(field, {})
|
|
1408
|
+
if not isinstance(dependencies, dict):
|
|
1409
|
+
return False
|
|
1410
|
+
if any(not isinstance(name, str) or not isinstance(spec, str) or not _dependency_spec_is_safe(spec) for name, spec in dependencies.items()):
|
|
1411
|
+
return False
|
|
1412
|
+
return _lockfile_uses_trusted_sources(lockfile_bytes)
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _minimal_node_environment(source: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
1416
|
+
ambient = os.environ if source is None else source
|
|
1417
|
+
environment = {name: ambient[name] for name in _LOCALE_ENVIRONMENT if name in ambient}
|
|
1418
|
+
if os.name == "nt":
|
|
1419
|
+
trusted_windows = _trusted_windows_system_environment()
|
|
1420
|
+
environment.update(trusted_windows)
|
|
1421
|
+
environment["PATH"] = str(Path(trusted_windows["SYSTEMROOT"]) / "System32")
|
|
1422
|
+
else:
|
|
1423
|
+
environment["PATH"] = "/usr/bin:/bin"
|
|
1424
|
+
return environment
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _command_revalidation_reason(command: TrustedCommand, root: Path) -> str | None:
|
|
1428
|
+
for index, reference in enumerate(command.references):
|
|
1429
|
+
executable = index == 0 or reference.launcher_path is not None
|
|
1430
|
+
if not revalidate_trusted_file(reference, root, executable=executable):
|
|
1431
|
+
return "executable_changed" if index == 0 else "command_changed"
|
|
1432
|
+
return None
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def run_validator(
|
|
1436
|
+
root: Path,
|
|
1437
|
+
node: TrustedFile,
|
|
1438
|
+
validator: TrustedFile,
|
|
1439
|
+
timeout: float,
|
|
1440
|
+
runner: Runner = run_bounded_process,
|
|
1441
|
+
) -> StepResult:
|
|
1442
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
1443
|
+
return StepResult(False, "validator_rejected")
|
|
1444
|
+
if not revalidate_trusted_file(node, root, executable=True):
|
|
1445
|
+
return StepResult(False, "executable_changed")
|
|
1446
|
+
if not revalidate_trusted_file(validator, root, executable=False):
|
|
1447
|
+
return StepResult(False, "validator_changed")
|
|
1448
|
+
try:
|
|
1449
|
+
result = runner(
|
|
1450
|
+
[str(node.path), str(validator.path), "typescript"],
|
|
1451
|
+
cwd=root,
|
|
1452
|
+
stdin=None,
|
|
1453
|
+
timeout_seconds=timeout,
|
|
1454
|
+
max_output_bytes=INSTALL_OUTPUT_LIMIT,
|
|
1455
|
+
check=False,
|
|
1456
|
+
environment=_minimal_node_environment(),
|
|
1457
|
+
)
|
|
1458
|
+
except ProbeProcessError as error:
|
|
1459
|
+
return StepResult(False, "validator_timeout" if error.code == "timeout" else "validator_rejected")
|
|
1460
|
+
except Exception:
|
|
1461
|
+
return StepResult(False, "validator_rejected")
|
|
1462
|
+
return StepResult(result.returncode == 0, "validated" if result.returncode == 0 else "validator_rejected")
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def run_manager_version(
|
|
1466
|
+
command: TrustedCommand,
|
|
1467
|
+
root: Path,
|
|
1468
|
+
timeout: float,
|
|
1469
|
+
runner: Runner = run_bounded_process,
|
|
1470
|
+
) -> VersionResult:
|
|
1471
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
1472
|
+
return VersionResult(False, "manager_unavailable")
|
|
1473
|
+
# Returned as-is, the way `run_install` already does. Flattened into
|
|
1474
|
+
# `manager_unavailable`, a command refused for changing under us read
|
|
1475
|
+
# exactly like one that was never installed -- two opposite operator
|
|
1476
|
+
# actions behind one string, on a security check.
|
|
1477
|
+
provenance_reason = _command_revalidation_reason(command, root)
|
|
1478
|
+
if provenance_reason is not None:
|
|
1479
|
+
return VersionResult(False, provenance_reason)
|
|
1480
|
+
try:
|
|
1481
|
+
result = runner(
|
|
1482
|
+
[*command.argv, "--version"],
|
|
1483
|
+
cwd=root,
|
|
1484
|
+
stdin=None,
|
|
1485
|
+
timeout_seconds=timeout,
|
|
1486
|
+
max_output_bytes=MANAGER_VERSION_OUTPUT_LIMIT,
|
|
1487
|
+
check=False,
|
|
1488
|
+
environment=_minimal_node_environment(),
|
|
1489
|
+
)
|
|
1490
|
+
except ProbeProcessError as error:
|
|
1491
|
+
reason = "manager_timeout" if error.code == "timeout" else "manager_unavailable"
|
|
1492
|
+
return VersionResult(False, reason)
|
|
1493
|
+
except Exception:
|
|
1494
|
+
return VersionResult(False, "manager_unavailable")
|
|
1495
|
+
if result.returncode != 0:
|
|
1496
|
+
return VersionResult(False, "manager_unavailable")
|
|
1497
|
+
version = parse_version(result.stdout)
|
|
1498
|
+
if version is None:
|
|
1499
|
+
return VersionResult(False, "manager_version_invalid")
|
|
1500
|
+
return VersionResult(True, "manager_versioned", version)
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def run_install(
|
|
1504
|
+
root: Path,
|
|
1505
|
+
command: TrustedCommand,
|
|
1506
|
+
adapter: ManagerAdapter,
|
|
1507
|
+
registry: str,
|
|
1508
|
+
isolated_home: Path,
|
|
1509
|
+
timeout: float,
|
|
1510
|
+
runner: Runner = run_bounded_process,
|
|
1511
|
+
) -> StepResult:
|
|
1512
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
1513
|
+
return StepResult(False, "install_failed")
|
|
1514
|
+
if registry != TRUSTED_REGISTRY:
|
|
1515
|
+
return StepResult(False, "install_failed")
|
|
1516
|
+
try:
|
|
1517
|
+
canonical_adapter = adapter_for(adapter.manager)
|
|
1518
|
+
if adapter is not canonical_adapter or not canonical_adapter.automatic:
|
|
1519
|
+
return StepResult(False, "install_failed")
|
|
1520
|
+
except (KeyError, ValueError):
|
|
1521
|
+
return StepResult(False, "install_failed")
|
|
1522
|
+
if not _prepare_isolated_install_home(root, isolated_home, adapter.manager):
|
|
1523
|
+
return StepResult(False, "install_failed")
|
|
1524
|
+
path_reference = (
|
|
1525
|
+
command.references[-1]
|
|
1526
|
+
if os.name != "nt" and command.references[-1].launcher_path is not None
|
|
1527
|
+
else command.references[0]
|
|
1528
|
+
)
|
|
1529
|
+
directories = (path_reference.command_path.parent,)
|
|
1530
|
+
try:
|
|
1531
|
+
environment = build_install_environment(
|
|
1532
|
+
adapter.manager, isolated_home, directories, TRUSTED_REGISTRY, os.environ
|
|
1533
|
+
)
|
|
1534
|
+
except (OSError, RuntimeError, ValueError):
|
|
1535
|
+
return StepResult(False, "install_failed")
|
|
1536
|
+
provenance_reason = _command_revalidation_reason(command, root)
|
|
1537
|
+
if provenance_reason is not None:
|
|
1538
|
+
return StepResult(False, provenance_reason)
|
|
1539
|
+
try:
|
|
1540
|
+
result = runner(
|
|
1541
|
+
[*command.argv, *adapter.argument_tail(TRUSTED_REGISTRY)],
|
|
1542
|
+
cwd=root,
|
|
1543
|
+
stdin=None,
|
|
1544
|
+
timeout_seconds=timeout,
|
|
1545
|
+
max_output_bytes=INSTALL_OUTPUT_LIMIT,
|
|
1546
|
+
check=False,
|
|
1547
|
+
environment=environment,
|
|
1548
|
+
)
|
|
1549
|
+
except ProbeProcessError as error:
|
|
1550
|
+
return StepResult(False, "install_timeout" if error.code == "timeout" else "install_failed")
|
|
1551
|
+
except Exception:
|
|
1552
|
+
return StepResult(False, "install_failed")
|
|
1553
|
+
return StepResult(result.returncode == 0, "installed_command" if result.returncode == 0 else "install_failed")
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def probe_local_typescript(
|
|
1557
|
+
root: Path,
|
|
1558
|
+
node: TrustedFile,
|
|
1559
|
+
timeout: float,
|
|
1560
|
+
runner: Runner = run_bounded_process,
|
|
1561
|
+
) -> bool:
|
|
1562
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
1563
|
+
return False
|
|
1564
|
+
if not revalidate_trusted_file(node, root, executable=True):
|
|
1565
|
+
return False
|
|
1566
|
+
try:
|
|
1567
|
+
result = runner(
|
|
1568
|
+
[str(node.path), "-e", _LOCAL_TYPESCRIPT_SCRIPT],
|
|
1569
|
+
cwd=root,
|
|
1570
|
+
stdin=None,
|
|
1571
|
+
timeout_seconds=timeout,
|
|
1572
|
+
max_output_bytes=4096,
|
|
1573
|
+
check=False,
|
|
1574
|
+
environment=_minimal_node_environment(),
|
|
1575
|
+
)
|
|
1576
|
+
payload: Any = json.loads(result.stdout)
|
|
1577
|
+
resolved_value = payload.get("resolved") if isinstance(payload, dict) else None
|
|
1578
|
+
if result.returncode != 0 or not isinstance(resolved_value, str):
|
|
1579
|
+
return False
|
|
1580
|
+
canonical_root = root.resolve(strict=True)
|
|
1581
|
+
package_root = canonical_root / "node_modules" / "typescript"
|
|
1582
|
+
candidate = Path(resolved_value)
|
|
1583
|
+
if not candidate.is_absolute() or _path_has_symlink(candidate.absolute()):
|
|
1584
|
+
return False
|
|
1585
|
+
canonical_candidate = candidate.resolve(strict=True)
|
|
1586
|
+
details = candidate.lstat()
|
|
1587
|
+
return (
|
|
1588
|
+
candidate.absolute() == canonical_candidate
|
|
1589
|
+
and _is_under(canonical_candidate, package_root)
|
|
1590
|
+
and stat.S_ISREG(details.st_mode)
|
|
1591
|
+
and not stat.S_ISLNK(details.st_mode)
|
|
1592
|
+
and not _is_reparse(details)
|
|
1593
|
+
)
|
|
1594
|
+
except (OSError, RuntimeError, UnicodeDecodeError, json.JSONDecodeError, ProbeProcessError, RecursionError, ValueError):
|
|
1595
|
+
return False
|
|
1596
|
+
except Exception:
|
|
1597
|
+
return False
|