pipelex-api 0.75.0__tar.gz → 0.76.0__tar.gz
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.
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/PKG-INFO +2 -2
- pipelex_api-0.76.0/pipelex_api/bundle.py +433 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/method_source.py +71 -45
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/pipelex/pipeline.py +93 -39
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/schemas/models.py +6 -1
- pipelex_api-0.75.0/pipelex_api/bundle.py +0 -243
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/.gitignore +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/LICENSE +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/README.md +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/hatch_build.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/__init__.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/api.toml +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/api_config.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/disclosure.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/error_types.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/error_uri.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/errors.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/exception_handlers.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/json_body.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/limits.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/main.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/method_cache.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/middleware.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/openapi_responses.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/openapi_schema.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/problem_document.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/py.typed +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/__init__.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/health.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/pipelex/__init__.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/pipelex/agent/models.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/pipelex/codegen.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/pipelex/crate_ops.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/pipelex/pipe_io.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/pipelex/resolve.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/pipelex/tools.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/pipelex/utils.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/pipelex/validate.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/routes/version.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/schemas/__init__.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pipelex_api/security.py +0 -0
- {pipelex_api-0.75.0 → pipelex_api-0.76.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pipelex-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.76.0
|
|
4
4
|
Summary: Pipelex API
|
|
5
5
|
Project-URL: Homepage, https://pipelex.com
|
|
6
6
|
Project-URL: Repository, https://github.com/Pipelex/pipelex
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
17
|
Requires-Python: <3.15,>=3.11
|
|
18
18
|
Requires-Dist: fastapi<0.142,>=0.118.0
|
|
19
|
-
Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.
|
|
19
|
+
Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.76.0
|
|
20
20
|
Requires-Dist: pyjwt>=2.10.1
|
|
21
21
|
Requires-Dist: uvicorn>=0.37.0
|
|
22
22
|
Provides-Extra: dev
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Materialize a caller-supplied method bundle into a temporary library directory.
|
|
2
|
+
|
|
3
|
+
A run request may carry the whole method — the `.mthds` bundle plus its PipeFunc
|
|
4
|
+
Python (`pipe_func.py`) and a `requirements.txt` — instead of only
|
|
5
|
+
the inline `mthds_contents` text. Two transport forms are accepted, exactly one
|
|
6
|
+
per request:
|
|
7
|
+
|
|
8
|
+
- `bundle_b64`: a base64-encoded zip archive of the bundle directory.
|
|
9
|
+
- `files`: a `{relative_path: text_content}` map (the zip's contents, unzipped).
|
|
10
|
+
|
|
11
|
+
The two are equivalent: `files` ≡ the zip's entries. This module decodes either
|
|
12
|
+
form, enforces the ingest guards (both transport forms are refused together; a
|
|
13
|
+
hard file-count and total-size ceiling; per-entry path-safety against absolute
|
|
14
|
+
paths and `..` traversal; a zip-bomb guard that bounds actual decompression),
|
|
15
|
+
writes the surviving files into a fresh temp directory, and hands that directory
|
|
16
|
+
back so the runner can load it via `library_dirs`.
|
|
17
|
+
|
|
18
|
+
A bundle may also ship the method packages it calls by address, each under
|
|
19
|
+
`.mthds/methods/<name>/` at its root with its `METHODS.toml` there, the layout the
|
|
20
|
+
standard names the local method cache. `partition_bundle_entries` sorts the entries
|
|
21
|
+
into the bundle's own `.mthds` files, its other files, and those packages, refusing
|
|
22
|
+
every other `.mthds` path with a `422 InvalidBundle` naming the entry, and two
|
|
23
|
+
packages declaring the same address with one naming both directories, and
|
|
24
|
+
`materialized_methods_dir` writes the packages into a temp directory of their own,
|
|
25
|
+
`<tmp>/<name>/…`, which the runner hands the engine as `methods_dirs`. In a sandbox-hosted
|
|
26
|
+
deployment the load path reads every `.py` as source text, never importing it:
|
|
27
|
+
it refuses a bundle whose Python declares a structure class
|
|
28
|
+
(`MethodStructuresRefusedError`, a 403) and captures the rest onto the crate for
|
|
29
|
+
the sandbox. The caller is responsible for the hosted-mode gate.
|
|
30
|
+
|
|
31
|
+
Nothing here imports or executes the bundle's Python — it only writes bytes to
|
|
32
|
+
disk. The caller cleans the directory up via the `materialized_bundle` context
|
|
33
|
+
manager's guaranteed teardown.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import base64
|
|
39
|
+
import binascii
|
|
40
|
+
import shutil
|
|
41
|
+
import tempfile
|
|
42
|
+
import zipfile
|
|
43
|
+
from contextlib import contextmanager
|
|
44
|
+
from io import BytesIO
|
|
45
|
+
from pathlib import Path, PurePosixPath
|
|
46
|
+
from typing import TYPE_CHECKING, NamedTuple, NoReturn
|
|
47
|
+
|
|
48
|
+
from mthds.package.discovery import MANIFEST_FILENAME
|
|
49
|
+
from mthds.package.exceptions import ManifestError
|
|
50
|
+
from mthds.package.manifest.parser import parse_methods_toml
|
|
51
|
+
from pipelex import log
|
|
52
|
+
from pydantic import ConfigDict
|
|
53
|
+
from pydantic.dataclasses import dataclass
|
|
54
|
+
|
|
55
|
+
from pipelex_api.error_types import ErrorType
|
|
56
|
+
from pipelex_api.errors import raise_bad_request, raise_payload_too_large, raise_validation_error
|
|
57
|
+
from pipelex_api.limits import MAX_BUNDLE_FILES, MAX_BUNDLE_TOTAL_BYTES
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
from collections.abc import Generator
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, config=ConfigDict(arbitrary_types_allowed=True))
|
|
64
|
+
class MaterializedBundle:
|
|
65
|
+
"""A bundle written to disk: the directory to load and the relpaths written."""
|
|
66
|
+
|
|
67
|
+
directory: Path
|
|
68
|
+
relpaths: tuple[str, ...]
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def has_python_sources(self) -> bool:
|
|
72
|
+
"""True when the bundle ships any `.py` — the trigger for the hosted-mode gate."""
|
|
73
|
+
return any(relpath.endswith(".py") for relpath in self.relpaths)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _safe_relpath(name: str) -> PurePosixPath:
|
|
77
|
+
"""Validate one bundle entry name and return it as a normalized relative POSIX path.
|
|
78
|
+
|
|
79
|
+
Rejects anything that could escape the destination directory: absolute paths,
|
|
80
|
+
Windows drive/backslash forms (a bare drive prefix like `C:foo` has no slash or
|
|
81
|
+
backslash yet is drive-relative on Windows, so `:` is rejected outright), and any
|
|
82
|
+
`..` component. Directory-only entries (trailing slash) return an empty path and
|
|
83
|
+
are filtered by the caller.
|
|
84
|
+
"""
|
|
85
|
+
if not name or name in {".", "./"}:
|
|
86
|
+
return PurePosixPath()
|
|
87
|
+
if "\\" in name:
|
|
88
|
+
msg = f"Bundle entry {name!r} uses backslashes; use forward-slash relative paths only"
|
|
89
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
90
|
+
if ":" in name:
|
|
91
|
+
msg = f"Bundle entry {name!r} contains ':' (a Windows drive/stream form); use plain relative paths only"
|
|
92
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
93
|
+
pure = PurePosixPath(name)
|
|
94
|
+
if pure.is_absolute():
|
|
95
|
+
msg = f"Bundle entry {name!r} is an absolute path; only relative paths are allowed"
|
|
96
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
97
|
+
if any(part == ".." for part in pure.parts):
|
|
98
|
+
msg = f"Bundle entry {name!r} escapes the bundle root via '..'; not allowed"
|
|
99
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
100
|
+
return pure
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _guard_count(count: int) -> None:
|
|
104
|
+
if count == 0:
|
|
105
|
+
raise_validation_error(message="Bundle is empty (no files)", error_type=ErrorType.INVALID_BUNDLE)
|
|
106
|
+
if count > MAX_BUNDLE_FILES:
|
|
107
|
+
raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_FILES}-file limit (got {count})")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _guard_running_total(total_bytes: int) -> None:
|
|
111
|
+
if total_bytes > MAX_BUNDLE_TOTAL_BYTES:
|
|
112
|
+
raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB decompressed-size limit")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Base64 inflates by 4/3; a zip is compressed, so a bundle whose DECOMPRESSED content is within
|
|
116
|
+
# the ceiling encodes to well under this. Bounding the base64 string BEFORE decoding stops a
|
|
117
|
+
# ~100 MiB request-body (the only other bound) from being expanded into ~75 MiB of heap just to
|
|
118
|
+
# be rejected later by the decompressed-size guard. Slack (+4) covers padding.
|
|
119
|
+
_MAX_BUNDLE_B64_CHARS = MAX_BUNDLE_TOTAL_BYTES * 4 // 3 + 4
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _entries_from_zip(bundle_b64: str) -> list[tuple[PurePosixPath, bytes]]:
|
|
123
|
+
"""Decode a base64 zip and return its (safe relpath, bytes) file entries.
|
|
124
|
+
|
|
125
|
+
Zip-bomb guard: each member is read through a bounded stream so a lying
|
|
126
|
+
uncompressed-size header cannot force unbounded decompression — the running
|
|
127
|
+
total is checked against `MAX_BUNDLE_TOTAL_BYTES` as bytes are pulled. A cheap
|
|
128
|
+
length check on the still-encoded string runs FIRST, so an oversized payload is
|
|
129
|
+
refused before it is buffered into memory as decoded bytes.
|
|
130
|
+
"""
|
|
131
|
+
if len(bundle_b64) > _MAX_BUNDLE_B64_CHARS:
|
|
132
|
+
raise_payload_too_large(message=f"bundle_b64 exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB compressed-size limit")
|
|
133
|
+
try:
|
|
134
|
+
raw = base64.b64decode(bundle_b64, validate=True)
|
|
135
|
+
except (binascii.Error, ValueError) as decode_error:
|
|
136
|
+
log.warning(f"bundle: invalid base64 ({decode_error})")
|
|
137
|
+
raise_bad_request(message="bundle_b64 is not valid base64", error_type=ErrorType.INVALID_BASE64)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
archive = zipfile.ZipFile(BytesIO(raw))
|
|
141
|
+
except zipfile.BadZipFile as zip_error:
|
|
142
|
+
log.warning(f"bundle: corrupt zip ({zip_error})")
|
|
143
|
+
raise_validation_error(message="bundle_b64 is not a valid zip archive", error_type=ErrorType.INVALID_BUNDLE)
|
|
144
|
+
|
|
145
|
+
entries: list[tuple[PurePosixPath, bytes]] = []
|
|
146
|
+
total_bytes = 0
|
|
147
|
+
with archive:
|
|
148
|
+
members = [info for info in archive.infolist() if not info.is_dir()]
|
|
149
|
+
_guard_count(len(members))
|
|
150
|
+
budget = MAX_BUNDLE_TOTAL_BYTES
|
|
151
|
+
for info in members:
|
|
152
|
+
relpath = _safe_relpath(info.filename)
|
|
153
|
+
if not relpath.parts:
|
|
154
|
+
continue
|
|
155
|
+
# Bounded read: pull at most (remaining budget + 1) bytes so a zip bomb whose header
|
|
156
|
+
# under-reports its size still cannot decompress past the ceiling.
|
|
157
|
+
with archive.open(info) as member:
|
|
158
|
+
data = member.read(budget + 1)
|
|
159
|
+
total_bytes += len(data)
|
|
160
|
+
_guard_running_total(total_bytes)
|
|
161
|
+
budget = MAX_BUNDLE_TOTAL_BYTES - total_bytes
|
|
162
|
+
entries.append((relpath, data))
|
|
163
|
+
return entries
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _entries_from_files(files: dict[str, str]) -> list[tuple[PurePosixPath, bytes]]:
|
|
167
|
+
r"""Validate a {relpath: text} map and return its (safe relpath, bytes) entries.
|
|
168
|
+
|
|
169
|
+
JSON can carry a lone surrogate as an escape (`"\ud800"`), which has no UTF-8 form, so an entry whose name or
|
|
170
|
+
content holds one is refused with a `422` naming it rather than failing later, when it is encoded or written.
|
|
171
|
+
"""
|
|
172
|
+
_guard_count(len(files))
|
|
173
|
+
entries: list[tuple[PurePosixPath, bytes]] = []
|
|
174
|
+
total_bytes = 0
|
|
175
|
+
for name, content in files.items():
|
|
176
|
+
try:
|
|
177
|
+
name.encode("utf-8")
|
|
178
|
+
except UnicodeEncodeError:
|
|
179
|
+
msg = f"Bundle entry {name!r} has a name holding a lone surrogate, which is not valid Unicode text"
|
|
180
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
181
|
+
relpath = _safe_relpath(name)
|
|
182
|
+
if not relpath.parts:
|
|
183
|
+
msg = f"Bundle entry {name!r} has no filename"
|
|
184
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
185
|
+
try:
|
|
186
|
+
data = content.encode("utf-8")
|
|
187
|
+
except UnicodeEncodeError:
|
|
188
|
+
msg = f"Bundle entry {name!r} holds a lone surrogate, which is not valid Unicode text"
|
|
189
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
190
|
+
total_bytes += len(data)
|
|
191
|
+
_guard_running_total(total_bytes)
|
|
192
|
+
entries.append((relpath, data))
|
|
193
|
+
return entries
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _refuse_colliding_paths(entries: list[tuple[PurePosixPath, bytes]]) -> None:
|
|
197
|
+
"""Refuse two entries of one path, and an entry that is also the directory of another.
|
|
198
|
+
|
|
199
|
+
Entry names are normalized (`a/./b` and `a//b` are `a/b`) and a zip may hold one member name twice, so two entries
|
|
200
|
+
can name one file: a check reading the first copy, as the shipped packages' manifest check does, would pass content
|
|
201
|
+
that differs from the last copy, which is the one written to disk and read by the engine. A file that another entry
|
|
202
|
+
is placed under could not be written at all.
|
|
203
|
+
"""
|
|
204
|
+
paths: set[PurePosixPath] = set()
|
|
205
|
+
for relpath, _ in entries:
|
|
206
|
+
if relpath in paths:
|
|
207
|
+
msg = f"Bundle has more than one entry for the path '{relpath.as_posix()}'; each file may appear only once"
|
|
208
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
209
|
+
paths.add(relpath)
|
|
210
|
+
for relpath, _ in entries:
|
|
211
|
+
for parent in relpath.parents:
|
|
212
|
+
if parent in paths:
|
|
213
|
+
msg = f"Bundle entry '{parent.as_posix()}' is a file, and also the directory of the entry '{relpath.as_posix()}'"
|
|
214
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ParsedBundle(NamedTuple):
|
|
218
|
+
"""A decoded-and-validated bundle held in memory, NOT yet written to disk.
|
|
219
|
+
|
|
220
|
+
Splitting parse from materialize lets the caller apply the sandbox-hosted gate
|
|
221
|
+
(which keys on `has_python_sources`) BEFORE any disk write — so a bundle destined
|
|
222
|
+
for a 403 never touches the filesystem. Entries are `(safe relpath, bytes)`.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
entries: tuple[tuple[PurePosixPath, bytes], ...]
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def has_python_sources(self) -> bool:
|
|
229
|
+
"""True when the bundle ships any `.py` — the trigger for the hosted-mode gate."""
|
|
230
|
+
return any(str(relpath).endswith(".py") for relpath, _ in self.entries)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_bundle(*, bundle_b64: str | None, files: dict[str, str] | None) -> ParsedBundle:
|
|
234
|
+
"""Decode + guard a bundle into an in-memory `ParsedBundle` (no disk writes).
|
|
235
|
+
|
|
236
|
+
Exactly one of `bundle_b64` / `files` must be supplied; supplying both is a
|
|
237
|
+
caller mistake (they are the same content in two forms) and is refused. All
|
|
238
|
+
ingest guards (base64, size, count, path-safety, zip-bomb, colliding paths) run
|
|
239
|
+
here, so the caller can inspect `has_python_sources` and reject BEFORE
|
|
240
|
+
materializing to disk.
|
|
241
|
+
"""
|
|
242
|
+
if bundle_b64 is not None and files is not None:
|
|
243
|
+
msg = "Provide either bundle_b64 or files, not both"
|
|
244
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
245
|
+
if bundle_b64 is not None:
|
|
246
|
+
entries = _entries_from_zip(bundle_b64)
|
|
247
|
+
elif files is not None:
|
|
248
|
+
entries = _entries_from_files(files)
|
|
249
|
+
else:
|
|
250
|
+
msg = "No bundle supplied (bundle_b64 and files are both absent)"
|
|
251
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
252
|
+
_refuse_colliding_paths(entries)
|
|
253
|
+
return ParsedBundle(entries=tuple(entries))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# Where a bundle ships the method packages it calls: `.mthds/methods/<name>/`, at the bundle's root and nowhere else.
|
|
257
|
+
_MTHDS_DIR_NAME = ".mthds"
|
|
258
|
+
_METHODS_DIR_NAME = "methods"
|
|
259
|
+
_VENDORING_LAYOUT = (
|
|
260
|
+
"A method package shipped with the bundle goes under '.mthds/methods/<name>/' at the bundle's root, "
|
|
261
|
+
"with its manifest at '.mthds/methods/<name>/METHODS.toml'."
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class VendoredMethodPackage(NamedTuple):
|
|
266
|
+
"""A method package a bundle ships under `.mthds/methods/<name>/`, held in memory."""
|
|
267
|
+
|
|
268
|
+
name: str
|
|
269
|
+
"""The package's directory name under `.mthds/methods/`."""
|
|
270
|
+
|
|
271
|
+
full_address: str
|
|
272
|
+
"""The address its manifest declares, `address/name`, as a reference names it and a refusal names the package."""
|
|
273
|
+
|
|
274
|
+
entries: tuple[tuple[PurePosixPath, bytes], ...]
|
|
275
|
+
"""Every file of the package, by its path relative to the package directory."""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class BundlePartition(NamedTuple):
|
|
279
|
+
"""A bundle's entries sorted into its own `.mthds` files, its other files, and the packages it ships."""
|
|
280
|
+
|
|
281
|
+
mthds_entries: tuple[tuple[PurePosixPath, bytes], ...]
|
|
282
|
+
library_entries: tuple[tuple[PurePosixPath, bytes], ...]
|
|
283
|
+
vendored_packages: tuple[VendoredMethodPackage, ...]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _refuse_entry(*, owner: str, relpath: PurePosixPath, reason: str) -> NoReturn:
|
|
287
|
+
msg = f"{owner} entry '{relpath.as_posix()}' {reason}. {_VENDORING_LAYOUT}"
|
|
288
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _vendored_package(*, owner: str, name: str, entries: list[tuple[PurePosixPath, bytes]]) -> VendoredMethodPackage:
|
|
292
|
+
"""Check one shipped package's directory and read the address its manifest declares."""
|
|
293
|
+
package_label = f"'.mthds/methods/{name}/'"
|
|
294
|
+
if name.startswith("."):
|
|
295
|
+
msg = f"{owner} ships {package_label}, a hidden directory, which is never searched for a package. {_VENDORING_LAYOUT}"
|
|
296
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
297
|
+
manifest_bytes = next((data for relpath, data in entries if relpath == PurePosixPath(MANIFEST_FILENAME)), None)
|
|
298
|
+
if manifest_bytes is None:
|
|
299
|
+
msg = f"{owner} ships {package_label} without a {MANIFEST_FILENAME}, so no reference could find it. {_VENDORING_LAYOUT}"
|
|
300
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
301
|
+
manifest_path = f".mthds/methods/{name}/{MANIFEST_FILENAME}"
|
|
302
|
+
try:
|
|
303
|
+
manifest = parse_methods_toml(manifest_bytes.decode("utf-8"))
|
|
304
|
+
except UnicodeDecodeError:
|
|
305
|
+
msg = f"{owner} entry '{manifest_path}' is not valid UTF-8. {_VENDORING_LAYOUT}"
|
|
306
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
307
|
+
except ManifestError as exc:
|
|
308
|
+
msg = f"{owner} entry '{manifest_path}' is not a valid method package manifest: {exc.message} {_VENDORING_LAYOUT}"
|
|
309
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
310
|
+
# The name a reference matches, as the engine's discovery gives it: the manifest's, else the directory's.
|
|
311
|
+
package_name = manifest.name if manifest.name is not None else name
|
|
312
|
+
return VendoredMethodPackage(name=name, full_address=f"{manifest.address}/{package_name}", entries=tuple(entries))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _refuse_shared_identities(*, owner: str, packages: tuple[VendoredMethodPackage, ...]) -> None:
|
|
316
|
+
"""Refuse shipped packages a reference could not tell apart, rather than let the directory order pick one.
|
|
317
|
+
|
|
318
|
+
A reference is matched against a package's full address without regard to case, as the engine's lookup compares it,
|
|
319
|
+
so two packages whose full addresses differ only in case are one identity too.
|
|
320
|
+
"""
|
|
321
|
+
by_identity: dict[str, list[VendoredMethodPackage]] = {}
|
|
322
|
+
for package in packages:
|
|
323
|
+
by_identity.setdefault(package.full_address.casefold(), []).append(package)
|
|
324
|
+
for sharing in by_identity.values():
|
|
325
|
+
if len(sharing) < 2:
|
|
326
|
+
continue
|
|
327
|
+
directories = ", ".join(f"'.mthds/methods/{package.name}/'" for package in sharing)
|
|
328
|
+
msg = (
|
|
329
|
+
f"{owner} ships several method packages declaring the address '{sharing[0].full_address}': {directories}. "
|
|
330
|
+
"A reference matches a package by its address without regard to case, so it could not tell them apart: "
|
|
331
|
+
"ship one package per address."
|
|
332
|
+
)
|
|
333
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def partition_bundle_entries(entries: tuple[tuple[PurePosixPath, bytes], ...], *, owner: str = "Bundle") -> BundlePartition:
|
|
337
|
+
"""Sort a bundle's entries into its own `.mthds` files, its other files, and the method packages it ships.
|
|
338
|
+
|
|
339
|
+
An entry under `.mthds/methods/<name>/`, whatever its extension, belongs to the shipped package `<name>`, which
|
|
340
|
+
leaves the bundle's own content and its library directory. Every other `.mthds` path component is refused with a
|
|
341
|
+
`422 InvalidBundle` naming the entry rather than dropped, since nothing could ever find what it holds: a file
|
|
342
|
+
elsewhere under `.mthds/`, a `.mthds/methods/` store below the root, a file directly under `.mthds/methods/`, and a
|
|
343
|
+
`.mthds/` inside a shipped package, whose own dependencies are not loaded. A shipped package must be a directory
|
|
344
|
+
whose name does not start with a dot and whose `METHODS.toml` parses, and the address that manifest declares is read
|
|
345
|
+
here, to name the package in a refusal. Two shipped packages declaring the same full address, compared without regard
|
|
346
|
+
to case as a reference is matched, are refused too, naming the address and both directories, since nothing but their
|
|
347
|
+
directory order would decide which one answers. Counting the bundle's own `.mthds` files is the caller's: a bundle
|
|
348
|
+
whose only `.mthds` files are shipped ones has no content of its own.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
entries: The bundle's `(safe relpath, bytes)` entries.
|
|
352
|
+
owner: How a refusal names the bundle, for a method package fetched by address as for a request's bundle.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
The three groups, each in the bundle's entry order.
|
|
356
|
+
"""
|
|
357
|
+
mthds_entries: list[tuple[PurePosixPath, bytes]] = []
|
|
358
|
+
library_entries: list[tuple[PurePosixPath, bytes]] = []
|
|
359
|
+
vendored_entries: dict[str, list[tuple[PurePosixPath, bytes]]] = {}
|
|
360
|
+
for relpath, data in entries:
|
|
361
|
+
parts = relpath.parts
|
|
362
|
+
if parts[0] == _MTHDS_DIR_NAME:
|
|
363
|
+
if len(parts) < 4 or parts[1] != _METHODS_DIR_NAME:
|
|
364
|
+
_refuse_entry(owner=owner, relpath=relpath, reason="is under '.mthds/' but not inside a shipped method package")
|
|
365
|
+
package_relpath = PurePosixPath(*parts[3:])
|
|
366
|
+
if _MTHDS_DIR_NAME in package_relpath.parts:
|
|
367
|
+
_refuse_entry(
|
|
368
|
+
owner=owner,
|
|
369
|
+
relpath=relpath,
|
|
370
|
+
reason="is a '.mthds/' directory inside a shipped method package, whose own dependencies are not loaded",
|
|
371
|
+
)
|
|
372
|
+
vendored_entries.setdefault(parts[2], []).append((package_relpath, data))
|
|
373
|
+
elif _MTHDS_DIR_NAME in parts:
|
|
374
|
+
_refuse_entry(owner=owner, relpath=relpath, reason="is under a '.mthds/' directory below the bundle's root, which is never searched")
|
|
375
|
+
elif relpath.suffix == ".mthds":
|
|
376
|
+
mthds_entries.append((relpath, data))
|
|
377
|
+
else:
|
|
378
|
+
library_entries.append((relpath, data))
|
|
379
|
+
vendored_packages = tuple(
|
|
380
|
+
_vendored_package(owner=owner, name=name, entries=package_entries) for name, package_entries in vendored_entries.items()
|
|
381
|
+
)
|
|
382
|
+
_refuse_shared_identities(owner=owner, packages=vendored_packages)
|
|
383
|
+
return BundlePartition(mthds_entries=tuple(mthds_entries), library_entries=tuple(library_entries), vendored_packages=vendored_packages)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@contextmanager
|
|
387
|
+
def materialize_parsed(parsed: ParsedBundle, *, prefix: str = "pipelex-bundle-") -> Generator[MaterializedBundle, None, None]:
|
|
388
|
+
"""Write an already-parsed bundle into a fresh temp directory, cleaned up on exit.
|
|
389
|
+
|
|
390
|
+
The yielded `MaterializedBundle.directory` is safe to pass as a `library_dirs`
|
|
391
|
+
entry; it is removed when the context exits, on both the happy and error path.
|
|
392
|
+
"""
|
|
393
|
+
directory = Path(tempfile.mkdtemp(prefix=prefix))
|
|
394
|
+
try:
|
|
395
|
+
root = directory.resolve()
|
|
396
|
+
relpaths: list[str] = []
|
|
397
|
+
for relpath, data in parsed.entries:
|
|
398
|
+
target = (directory / relpath).resolve()
|
|
399
|
+
# Defense-in-depth: even after per-part validation, confirm the resolved target stays
|
|
400
|
+
# under the temp root before writing (guards against symlink/edge normalization surprises).
|
|
401
|
+
if root != target and root not in target.parents:
|
|
402
|
+
msg = f"Bundle entry {str(relpath)!r} resolves outside the bundle root"
|
|
403
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
404
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
405
|
+
target.write_bytes(data)
|
|
406
|
+
relpaths.append(relpath.as_posix())
|
|
407
|
+
yield MaterializedBundle(directory=directory, relpaths=tuple(relpaths))
|
|
408
|
+
finally:
|
|
409
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@contextmanager
|
|
413
|
+
def materialized_methods_dir(packages: tuple[VendoredMethodPackage, ...]) -> Generator[Path, None, None]:
|
|
414
|
+
"""Write shipped method packages into a temp directory of their own, as `<tmp>/<name>/…`, cleaned up on exit.
|
|
415
|
+
|
|
416
|
+
The directory is laid out like `.mthds/methods/`, one directory per package, which is what the engine's
|
|
417
|
+
`methods_dirs` take, and its path holds no `.mthds/methods` a walk-up from a bundle could take for a store.
|
|
418
|
+
"""
|
|
419
|
+
entries = tuple((PurePosixPath(package.name) / relpath, data) for package in packages for relpath, data in package.entries)
|
|
420
|
+
with materialize_parsed(ParsedBundle(entries=entries), prefix="pipelex-methods-") as materialized:
|
|
421
|
+
yield materialized.directory
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@contextmanager
|
|
425
|
+
def materialized_bundle(*, bundle_b64: str | None, files: dict[str, str] | None) -> Generator[MaterializedBundle, None, None]:
|
|
426
|
+
"""Parse AND materialize a bundle in one step (convenience for callers that don't gate).
|
|
427
|
+
|
|
428
|
+
Equivalent to `parse_bundle(...)` followed by `materialize_parsed(...)`; a caller
|
|
429
|
+
that must apply the sandbox-hosted gate before disk writes should use the two
|
|
430
|
+
steps directly instead.
|
|
431
|
+
"""
|
|
432
|
+
with materialize_parsed(parse_bundle(bundle_b64=bundle_b64, files=files)) as bundle:
|
|
433
|
+
yield bundle
|
|
@@ -12,11 +12,15 @@ manifest-identity package location, the bounds, and the structures check all liv
|
|
|
12
12
|
files travel as `mthds_contents` (paired with their real relative paths as
|
|
13
13
|
`mthds_sources`, so diagnostics carry true per-file labels), and only the non-`.mthds`
|
|
14
14
|
files are materialized into a temporary `library_dirs` entry — exactly the split the
|
|
15
|
-
method-bundle transport uses, so a fetched package runs the same proven path.
|
|
15
|
+
method-bundle transport uses, so a fetched package runs the same proven path. The
|
|
16
|
+
packages it ships under its own `.mthds/methods/<name>/` are partitioned out of both, as
|
|
17
|
+
a bundle's are, into a temporary `methods_dirs` entry: the run routes hand it to the
|
|
18
|
+
engine, and `/validate` does not, resolving such a dependency through the installed store
|
|
19
|
+
or fetch-on-miss.
|
|
16
20
|
- The tooling routes (`/resolve`, `/codegen`, `/pipe-io`) use
|
|
17
|
-
:func:`fetch_method_mthds_files`: only the `.mthds` files, as `files[]` items,
|
|
18
|
-
the manifest's `main_pipe` so the per-pipe route defaults its selector exactly
|
|
19
|
-
does. No Python ever loads there, so the execution-locus gate does not apply.
|
|
21
|
+
:func:`fetch_method_mthds_files`: only the package's own `.mthds` files, as `files[]` items,
|
|
22
|
+
paired with the manifest's `main_pipe` so the per-pipe route defaults its selector exactly
|
|
23
|
+
as a run does. No Python ever loads there, so the execution-locus gate does not apply.
|
|
20
24
|
|
|
21
25
|
The security gate (packaging invariant 7 — execution locus decides): `.mthds` content is
|
|
22
26
|
data, always acceptable. On a deployment that is NOT sandbox-hosted, a fetched package
|
|
@@ -34,9 +38,9 @@ can never race a running pipeline.
|
|
|
34
38
|
"""
|
|
35
39
|
|
|
36
40
|
from collections.abc import Generator
|
|
37
|
-
from contextlib import contextmanager
|
|
41
|
+
from contextlib import ExitStack, contextmanager
|
|
38
42
|
from pathlib import Path, PurePosixPath
|
|
39
|
-
from typing import NamedTuple
|
|
43
|
+
from typing import NamedTuple, NoReturn
|
|
40
44
|
|
|
41
45
|
from mthds.package.discovery import MANIFEST_FILENAME
|
|
42
46
|
from pipelex import log
|
|
@@ -46,7 +50,7 @@ from pipelex.methods.method_ref import parse_method_ref
|
|
|
46
50
|
from pipelex.methods.structures_check import ensure_no_structured_content_python
|
|
47
51
|
from pydantic import BaseModel, ConfigDict, Field
|
|
48
52
|
|
|
49
|
-
from pipelex_api.bundle import ParsedBundle, materialize_parsed
|
|
53
|
+
from pipelex_api.bundle import BundlePartition, ParsedBundle, materialize_parsed, materialized_methods_dir, partition_bundle_entries
|
|
50
54
|
from pipelex_api.error_types import ErrorType
|
|
51
55
|
from pipelex_api.errors import raise_forbidden, raise_validation_error
|
|
52
56
|
from pipelex_api.limits import MAX_MTHDS_FILE_BYTES
|
|
@@ -64,6 +68,9 @@ class FetchedMethodSource(BaseModel):
|
|
|
64
68
|
mthds_contents: list[str]
|
|
65
69
|
mthds_sources: list[str]
|
|
66
70
|
library_dirs: list[str] | None = None
|
|
71
|
+
methods_dirs: list[Path] | None = Field(
|
|
72
|
+
default=None, description="The methods directory holding the packages the package ships under its own `.mthds/methods/`."
|
|
73
|
+
)
|
|
67
74
|
main_pipe: str | None = Field(default=None, description="The manifest's declared entry pipe; a request `pipe_code` overrides it.")
|
|
68
75
|
provenance: MethodProvenance
|
|
69
76
|
|
|
@@ -106,17 +113,36 @@ def _package_files(package: FetchedMethodPackage) -> list[Path]:
|
|
|
106
113
|
return files
|
|
107
114
|
|
|
108
115
|
|
|
109
|
-
def
|
|
116
|
+
def _decode_mthds_text(data: bytes, *, relative: str, package_address: str) -> str:
|
|
110
117
|
try:
|
|
111
|
-
content =
|
|
118
|
+
content = data.decode("utf-8")
|
|
112
119
|
except UnicodeDecodeError:
|
|
113
120
|
raise_validation_error(message=f"File '{relative}' in method package '{package_address}' is not valid UTF-8.")
|
|
114
|
-
if len(
|
|
121
|
+
if len(data) > MAX_MTHDS_FILE_BYTES:
|
|
115
122
|
msg = f"File '{relative}' in method package '{package_address}' exceeds the {MAX_MTHDS_FILE_BYTES // 1024} KiB per-file limit."
|
|
116
123
|
raise_validation_error(message=msg)
|
|
117
124
|
return content
|
|
118
125
|
|
|
119
126
|
|
|
127
|
+
def _partition_package(package: FetchedMethodPackage, *, files: list[Path]) -> BundlePartition:
|
|
128
|
+
"""Sort a fetched package's files as a bundle's are: its own `.mthds`, its other files, and the packages it ships.
|
|
129
|
+
|
|
130
|
+
The refusals are a bundle's too: a `.mthds` path no reference could find is a `422` naming the file.
|
|
131
|
+
"""
|
|
132
|
+
entries = tuple((PurePosixPath(file_path.relative_to(package.package_dir).as_posix()), file_path.read_bytes()) for file_path in files)
|
|
133
|
+
return partition_bundle_entries(entries, owner=f"Method package '{package.full_address}'")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _refuse_no_own_mthds(package: FetchedMethodPackage, *, partition: BundlePartition) -> NoReturn:
|
|
137
|
+
msg = f"Method package '{package.full_address}' contains no .mthds file."
|
|
138
|
+
if partition.vendored_packages:
|
|
139
|
+
msg = (
|
|
140
|
+
f"Method package '{package.full_address}' contains no .mthds file of its own: "
|
|
141
|
+
"the files under '.mthds/methods/' are the packages it ships."
|
|
142
|
+
)
|
|
143
|
+
raise_validation_error(message=msg)
|
|
144
|
+
|
|
145
|
+
|
|
120
146
|
def _apply_execution_locus_gate(package: FetchedMethodPackage, *, python_relpaths: list[str]) -> None:
|
|
121
147
|
"""Refuse Python that would execute where it must not (see the module docstring)."""
|
|
122
148
|
if not python_relpaths:
|
|
@@ -131,10 +157,11 @@ def _apply_execution_locus_gate(package: FetchedMethodPackage, *, python_relpath
|
|
|
131
157
|
def fetched_method_source(method_ref: str) -> Generator[FetchedMethodSource, None, None]:
|
|
132
158
|
"""Fetch a `method_ref`'s package and shape it for the run/validate path.
|
|
133
159
|
|
|
134
|
-
Yields the package's `.mthds` files as `(mthds_contents, mthds_sources)` pairs
|
|
160
|
+
Yields the package's `.mthds` files as `(mthds_contents, mthds_sources)` pairs, its
|
|
135
161
|
non-`.mthds` files (PipeFunc `.py`, `requirements.txt`, …) materialized into a temporary
|
|
136
|
-
`library_dirs` entry,
|
|
137
|
-
|
|
162
|
+
`library_dirs` entry, and the packages it ships under its own `.mthds/methods/<name>/`
|
|
163
|
+
materialized into a temporary `methods_dirs` entry, each cleaned up on exit. The
|
|
164
|
+
execution-locus gate runs before anything touches disk.
|
|
138
165
|
|
|
139
166
|
Args:
|
|
140
167
|
method_ref: The raw `method_ref` string from the request.
|
|
@@ -148,37 +175,35 @@ def fetched_method_source(method_ref: str) -> Generator[FetchedMethodSource, Non
|
|
|
148
175
|
package = _fetch_package(method_ref)
|
|
149
176
|
files = _package_files(package)
|
|
150
177
|
python_relpaths = [file_path.relative_to(package.package_dir).as_posix() for file_path in files if file_path.suffix == ".py"]
|
|
178
|
+
# The whole package, the packages it ships included, so a structure class anywhere in it is refused before anything is copied.
|
|
151
179
|
_apply_execution_locus_gate(package, python_relpaths=python_relpaths)
|
|
152
180
|
|
|
181
|
+
partition = _partition_package(package, files=files)
|
|
153
182
|
mthds_contents: list[str] = []
|
|
154
183
|
mthds_sources: list[str] = []
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
relative =
|
|
158
|
-
|
|
159
|
-
mthds_contents.append(_read_mthds_text(file_path, relative=relative, package_address=package.full_address))
|
|
160
|
-
mthds_sources.append(relative)
|
|
161
|
-
elif file_path.name != MANIFEST_FILENAME:
|
|
162
|
-
# The manifest is already consumed (identity + `main_pipe`); materializing it into
|
|
163
|
-
# the library dir would hand the local loader a package boundary it must not see.
|
|
164
|
-
other_entries.append((PurePosixPath(relative), file_path.read_bytes()))
|
|
184
|
+
for relpath, data in partition.mthds_entries:
|
|
185
|
+
relative = relpath.as_posix()
|
|
186
|
+
mthds_contents.append(_decode_mthds_text(data, relative=relative, package_address=package.full_address))
|
|
187
|
+
mthds_sources.append(relative)
|
|
165
188
|
if not mthds_contents:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
189
|
+
_refuse_no_own_mthds(package, partition=partition)
|
|
190
|
+
# The manifest is already consumed (identity + `main_pipe`); materializing it into the library dir would hand the
|
|
191
|
+
# local loader a package boundary it must not see.
|
|
192
|
+
other_entries = tuple((relpath, data) for relpath, data in partition.library_entries if relpath.name != MANIFEST_FILENAME)
|
|
193
|
+
|
|
194
|
+
with ExitStack() as stack:
|
|
195
|
+
library_dirs: list[str] | None = None
|
|
196
|
+
if other_entries:
|
|
197
|
+
library_bundle = stack.enter_context(materialize_parsed(ParsedBundle(entries=other_entries)))
|
|
198
|
+
library_dirs = [str(library_bundle.directory)]
|
|
199
|
+
methods_dirs: list[Path] | None = None
|
|
200
|
+
if partition.vendored_packages:
|
|
201
|
+
methods_dirs = [stack.enter_context(materialized_methods_dir(partition.vendored_packages))]
|
|
169
202
|
yield FetchedMethodSource(
|
|
170
203
|
mthds_contents=mthds_contents,
|
|
171
204
|
mthds_sources=mthds_sources,
|
|
172
|
-
library_dirs=
|
|
173
|
-
|
|
174
|
-
provenance=package.provenance,
|
|
175
|
-
)
|
|
176
|
-
return
|
|
177
|
-
with materialize_parsed(ParsedBundle(entries=tuple(other_entries))) as bundle:
|
|
178
|
-
yield FetchedMethodSource(
|
|
179
|
-
mthds_contents=mthds_contents,
|
|
180
|
-
mthds_sources=mthds_sources,
|
|
181
|
-
library_dirs=[str(bundle.directory)],
|
|
205
|
+
library_dirs=library_dirs,
|
|
206
|
+
methods_dirs=methods_dirs,
|
|
182
207
|
main_pipe=package.manifest.main_pipe,
|
|
183
208
|
provenance=package.provenance,
|
|
184
209
|
)
|
|
@@ -188,9 +213,11 @@ def fetch_method_mthds_files(method_ref: str) -> FetchedMthdsFiles:
|
|
|
188
213
|
"""Fetch a `method_ref`'s package and return its `.mthds` files as `files[]` items, plus its `main_pipe`.
|
|
189
214
|
|
|
190
215
|
The tooling-route shape: each item pairs the file's content with its real relative path
|
|
191
|
-
as `source`, so crate provenance and diagnostics carry true per-file labels. Only
|
|
192
|
-
`.mthds` data travels
|
|
193
|
-
|
|
216
|
+
as `source`, so crate provenance and diagnostics carry true per-file labels. Only the
|
|
217
|
+
package's own `.mthds` data travels, never that of a package it ships under its
|
|
218
|
+
`.mthds/methods/`, since these routes resolve no address-based dependency. The package's
|
|
219
|
+
Python (if any) never loads on these routes, so the execution-locus gate does not apply
|
|
220
|
+
here. The manifest's `main_pipe` rides beside the
|
|
194
221
|
files so a per-pipe projection can default its selector the way a run does — the manifest
|
|
195
222
|
is the package author's declaration of the entry pipe, and dropping it here would make
|
|
196
223
|
`main_pipe` buy them nothing on the tooling routes.
|
|
@@ -203,13 +230,12 @@ def fetch_method_mthds_files(method_ref: str) -> FetchedMthdsFiles:
|
|
|
203
230
|
ApiError: 422 for a package with no `.mthds` file or one this server cannot accept.
|
|
204
231
|
"""
|
|
205
232
|
package = _fetch_package(method_ref)
|
|
233
|
+
partition = _partition_package(package, files=_package_files(package))
|
|
206
234
|
items: list[MthdsFileItem] = []
|
|
207
|
-
for
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
relative = file_path.relative_to(package.package_dir).as_posix()
|
|
211
|
-
content = _read_mthds_text(file_path, relative=relative, package_address=package.full_address)
|
|
235
|
+
for relpath, data in partition.mthds_entries:
|
|
236
|
+
relative = relpath.as_posix()
|
|
237
|
+
content = _decode_mthds_text(data, relative=relative, package_address=package.full_address)
|
|
212
238
|
items.append(MthdsFileItem(content=content, source=relative))
|
|
213
239
|
if not items:
|
|
214
|
-
|
|
240
|
+
_refuse_no_own_mthds(package, partition=partition)
|
|
215
241
|
return FetchedMthdsFiles(files=items, main_pipe=package.manifest.main_pipe)
|
|
@@ -2,8 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import hmac
|
|
5
|
-
from contextlib import contextmanager
|
|
6
|
-
from pathlib import Path
|
|
5
|
+
from contextlib import ExitStack, contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
7
|
from typing import TYPE_CHECKING, Annotated, Any, NamedTuple, cast
|
|
8
8
|
|
|
9
9
|
from fastapi import APIRouter, Depends, Request
|
|
@@ -11,6 +11,7 @@ from fastapi.responses import JSONResponse
|
|
|
11
11
|
from mthds.protocol.exceptions import PipelineRequestError
|
|
12
12
|
from pipelex.config import get_config, is_pipe_func_sandbox_hosted
|
|
13
13
|
from pipelex.core.pipes.pipe_output import PipeOutput
|
|
14
|
+
from pipelex.methods.structures_check import ensure_no_structured_content_python
|
|
14
15
|
from pipelex.pipe_run.delivery_assignment import DeliveryAssignment, StorageTarget, WebhookTarget
|
|
15
16
|
from pipelex.pipe_run.pipe_run_protocol import PipeRunProtocol
|
|
16
17
|
from pipelex.pipeline.pipeline_response import PipelexRunResultExecute, PipelexRunResultStart, RunState
|
|
@@ -26,7 +27,14 @@ from pydantic import ValidationError
|
|
|
26
27
|
from typing_extensions import override
|
|
27
28
|
|
|
28
29
|
from pipelex_api.api_config import get_api_config, resolve_orchestration_mode
|
|
29
|
-
from pipelex_api.bundle import
|
|
30
|
+
from pipelex_api.bundle import (
|
|
31
|
+
ParsedBundle,
|
|
32
|
+
VendoredMethodPackage,
|
|
33
|
+
materialize_parsed,
|
|
34
|
+
materialized_methods_dir,
|
|
35
|
+
parse_bundle,
|
|
36
|
+
partition_bundle_entries,
|
|
37
|
+
)
|
|
30
38
|
from pipelex_api.error_types import ErrorType
|
|
31
39
|
from pipelex_api.errors import raise_bad_request, raise_forbidden, raise_validation_error
|
|
32
40
|
from pipelex_api.json_body import decode_json_body
|
|
@@ -407,6 +415,8 @@ class ApiRunner(PipelexMTHDSProtocol):
|
|
|
407
415
|
# here, so a group left out of this call is dropped with no error
|
|
408
416
|
# and the run's spans lose their groups while the ack still says 202.
|
|
409
417
|
extras=self.extras,
|
|
418
|
+
# The packages the request ships, which the base `execute` threads too.
|
|
419
|
+
methods_dirs=self.methods_dirs,
|
|
410
420
|
pipeline_run_id=pipeline_run_id,
|
|
411
421
|
request_id=request_id,
|
|
412
422
|
)
|
|
@@ -685,31 +695,58 @@ async def _parse_request(request: Request) -> tuple[RunRequest, PipelineApiExtra
|
|
|
685
695
|
return run_request, extras
|
|
686
696
|
|
|
687
697
|
|
|
698
|
+
class _BundleRunSource(NamedTuple):
|
|
699
|
+
"""What a request's run source resolves to, before the routes add its entry pipe and provenance."""
|
|
700
|
+
|
|
701
|
+
mthds_contents: list[str] | None
|
|
702
|
+
library_dirs: list[str] | None
|
|
703
|
+
methods_dirs: list[Path] | None
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _ensure_vendored_packages_hold_no_structures(*, methods_dir: Path, packages: tuple[VendoredMethodPackage, ...]) -> None:
|
|
707
|
+
"""Refuse a shipped package whose Python declares a structure class, as a fetched package is on this deployment.
|
|
708
|
+
|
|
709
|
+
The library load refuses the bundle's own structure classes, but never reads a shipped package's Python, which is
|
|
710
|
+
not in a library directory. Each package is named by the address its manifest declares, and each file by its path
|
|
711
|
+
inside the package, never by the temporary directory.
|
|
712
|
+
"""
|
|
713
|
+
for package in packages:
|
|
714
|
+
if any(relpath.suffix == ".py" for relpath, _ in package.entries):
|
|
715
|
+
ensure_no_structured_content_python(package_dir=methods_dir / package.name, package_address=package.full_address)
|
|
716
|
+
|
|
717
|
+
|
|
688
718
|
@contextmanager
|
|
689
|
-
def _bundle_run_source(run_request: RunRequest) -> Generator[
|
|
690
|
-
"""Resolve a run's `(mthds_contents, library_dirs)` from the request.
|
|
719
|
+
def _bundle_run_source(run_request: RunRequest) -> Generator[_BundleRunSource, None, None]:
|
|
720
|
+
"""Resolve a run's `(mthds_contents, library_dirs, methods_dirs)` from the request.
|
|
691
721
|
|
|
692
|
-
A method bundle KEEPS the proven run path rather than replacing it: its `.mthds`
|
|
722
|
+
A method bundle KEEPS the proven run path rather than replacing it: its own `.mthds`
|
|
693
723
|
text travels as `mthds_contents` — so `main_pipe` resolves exactly as it does for a
|
|
694
|
-
plain (non-bundle) run — and
|
|
724
|
+
plain (non-bundle) run — and its other files (custom PipeFunc `.py`,
|
|
695
725
|
`requirements.txt`) are materialized into a temp `library_dirs` entry, where the
|
|
696
726
|
load path reads them without importing them. This mirrors "a normal run, plus the Python", instead of
|
|
697
727
|
handing the engine a bare directory with no `mthds_contents` (which never resolves
|
|
698
728
|
`main_pipe`, since that is only derived from `mthds_contents`).
|
|
699
729
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
bundle
|
|
708
|
-
|
|
709
|
-
|
|
730
|
+
The method packages the bundle ships under `.mthds/methods/<name>/` belong to neither:
|
|
731
|
+
every file of theirs is written into a temp directory of its own, as `<tmp>/<name>/…`,
|
|
732
|
+
which the engine searches first for an address-based reference and never writes, so a
|
|
733
|
+
shipped package wins over an installed copy and ends with the request. The other `.mthds`
|
|
734
|
+
paths are refused with a `422` naming the entry (`partition_bundle_entries`), and so are two
|
|
735
|
+
shipped packages declaring the same address and a bundle whose only `.mthds` files are shipped ones.
|
|
736
|
+
|
|
737
|
+
No bundle → yields the request's own `mthds_contents` and no directory (the classic path,
|
|
738
|
+
unchanged). Each temp dir is created only when it has files, and cleaned up on exit.
|
|
739
|
+
|
|
740
|
+
Security gate (decision 5): a bundle that ships custom Python (`.py`), in a shipped package
|
|
741
|
+
or not, is only honored on a sandbox-hosted deployment, where the load path reads the bundle's
|
|
742
|
+
own source without importing it and refuses, with a 403 `MethodStructuresRefusedError`, a
|
|
743
|
+
bundle whose Python declares a structure class; PipeFunc source is captured for the sandbox.
|
|
744
|
+
A shipped package's Python is checked the same way, as a fetched package's is, and never runs.
|
|
745
|
+
On a non-hosted deployment, running that code would import it in-process — refused with a 403
|
|
746
|
+
rather than executing untrusted code.
|
|
710
747
|
"""
|
|
711
748
|
if run_request.bundle_b64 is None and run_request.files is None:
|
|
712
|
-
yield run_request.mthds_contents, None
|
|
749
|
+
yield _BundleRunSource(mthds_contents=run_request.mthds_contents, library_dirs=None, methods_dirs=None)
|
|
713
750
|
return
|
|
714
751
|
# Parse + guard in memory FIRST, then apply the sandbox-hosted gate BEFORE any disk write —
|
|
715
752
|
# a bundle destined for a 403 on a non-hosted deployment never touches the filesystem.
|
|
@@ -717,25 +754,36 @@ def _bundle_run_source(run_request: RunRequest) -> Generator[tuple[list[str] | N
|
|
|
717
754
|
if parsed.has_python_sources and not is_pipe_func_sandbox_hosted():
|
|
718
755
|
msg = "This bundle ships custom Python (.py); running it requires a sandbox-hosted deployment."
|
|
719
756
|
raise_forbidden(message=msg, error_type=ErrorType.CUSTOM_CODE_REQUIRES_SANDBOX)
|
|
720
|
-
# Split: `.mthds` text → `mthds_contents` (the proven main_pipe path);
|
|
721
|
-
# (`.py`, `requirements.txt`) → a temp `library_dirs` entry the load path source-captures
|
|
757
|
+
# Split: the bundle's own `.mthds` text → `mthds_contents` (the proven main_pipe path); its other files
|
|
758
|
+
# (`.py`, `requirements.txt`) → a temp `library_dirs` entry the load path source-captures; the packages it
|
|
759
|
+
# ships → a temp methods directory.
|
|
760
|
+
partition = partition_bundle_entries(parsed.entries)
|
|
722
761
|
mthds_contents: list[str] = []
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
except UnicodeDecodeError:
|
|
729
|
-
raise_validation_error(message=f"Bundle .mthds file '{relpath}' is not valid UTF-8.", error_type=ErrorType.INVALID_BUNDLE)
|
|
730
|
-
else:
|
|
731
|
-
other_entries.append((relpath, content))
|
|
762
|
+
for relpath, content in partition.mthds_entries:
|
|
763
|
+
try:
|
|
764
|
+
mthds_contents.append(content.decode("utf-8"))
|
|
765
|
+
except UnicodeDecodeError:
|
|
766
|
+
raise_validation_error(message=f"Bundle .mthds file '{relpath}' is not valid UTF-8.", error_type=ErrorType.INVALID_BUNDLE)
|
|
732
767
|
if not mthds_contents:
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
768
|
+
msg = "Method bundle contains no .mthds file."
|
|
769
|
+
if partition.vendored_packages:
|
|
770
|
+
msg = (
|
|
771
|
+
"Method bundle contains no .mthds file of its own: the files under '.mthds/methods/<name>/' are the method "
|
|
772
|
+
"packages it ships, and the bundle's own .mthds files go outside '.mthds/'."
|
|
773
|
+
)
|
|
774
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
775
|
+
with ExitStack() as stack:
|
|
776
|
+
library_dirs: list[str] | None = None
|
|
777
|
+
if partition.library_entries:
|
|
778
|
+
library_bundle = stack.enter_context(materialize_parsed(ParsedBundle(entries=partition.library_entries)))
|
|
779
|
+
library_dirs = [str(library_bundle.directory)]
|
|
780
|
+
methods_dirs: list[Path] | None = None
|
|
781
|
+
if partition.vendored_packages:
|
|
782
|
+
methods_dir = stack.enter_context(materialized_methods_dir(partition.vendored_packages))
|
|
783
|
+
if is_pipe_func_sandbox_hosted():
|
|
784
|
+
_ensure_vendored_packages_hold_no_structures(methods_dir=methods_dir, packages=partition.vendored_packages)
|
|
785
|
+
methods_dirs = [methods_dir]
|
|
786
|
+
yield _BundleRunSource(mthds_contents=mthds_contents, library_dirs=library_dirs, methods_dirs=methods_dirs)
|
|
739
787
|
|
|
740
788
|
|
|
741
789
|
class _ResolvedRunSource(NamedTuple):
|
|
@@ -743,6 +791,7 @@ class _ResolvedRunSource(NamedTuple):
|
|
|
743
791
|
|
|
744
792
|
mthds_contents: list[str] | None
|
|
745
793
|
library_dirs: list[str] | None
|
|
794
|
+
methods_dirs: list[Path] | None
|
|
746
795
|
pipe_code: str | None
|
|
747
796
|
method_provenance: MethodProvenance | None
|
|
748
797
|
|
|
@@ -753,7 +802,8 @@ def _run_source(run_request: RunRequest) -> Generator[_ResolvedRunSource, None,
|
|
|
753
802
|
|
|
754
803
|
A `method_ref` resolves through `pipelex_api.method_source.fetched_method_source` (fetch → locate →
|
|
755
804
|
execution-locus gate → materialize) into exactly the shape a bundle produces — `.mthds` text
|
|
756
|
-
as `mthds_contents`, non-`.mthds` files in a temp `library_dirs` entry
|
|
805
|
+
as `mthds_contents`, non-`.mthds` files in a temp `library_dirs` entry, the packages the package
|
|
806
|
+
ships under its own `.mthds/methods/` in a temp `methods_dirs` entry — so the engine runs
|
|
757
807
|
the same proven path. The entry pipe defaults to the fetched manifest's `main_pipe`; a
|
|
758
808
|
request `pipe_code` overrides it. Provenance `(address, tag, commit_sha)` rides back so the
|
|
759
809
|
routes can put it on the response. The other two forms delegate to `_bundle_run_source`,
|
|
@@ -764,14 +814,16 @@ def _run_source(run_request: RunRequest) -> Generator[_ResolvedRunSource, None,
|
|
|
764
814
|
yield _ResolvedRunSource(
|
|
765
815
|
mthds_contents=fetched.mthds_contents,
|
|
766
816
|
library_dirs=fetched.library_dirs,
|
|
817
|
+
methods_dirs=fetched.methods_dirs,
|
|
767
818
|
pipe_code=run_request.pipe_code or fetched.main_pipe,
|
|
768
819
|
method_provenance=fetched.provenance,
|
|
769
820
|
)
|
|
770
821
|
return
|
|
771
|
-
with _bundle_run_source(run_request) as
|
|
822
|
+
with _bundle_run_source(run_request) as bundle_source:
|
|
772
823
|
yield _ResolvedRunSource(
|
|
773
|
-
mthds_contents=mthds_contents,
|
|
774
|
-
library_dirs=library_dirs,
|
|
824
|
+
mthds_contents=bundle_source.mthds_contents,
|
|
825
|
+
library_dirs=bundle_source.library_dirs,
|
|
826
|
+
methods_dirs=bundle_source.methods_dirs,
|
|
775
827
|
pipe_code=run_request.pipe_code,
|
|
776
828
|
method_provenance=None,
|
|
777
829
|
)
|
|
@@ -825,6 +877,7 @@ async def execute(request: Request) -> JSONResponse:
|
|
|
825
877
|
read_scope=scopes.read_scope,
|
|
826
878
|
extras=extras.analytics_groups,
|
|
827
879
|
library_dirs=source.library_dirs,
|
|
880
|
+
methods_dirs=source.methods_dirs,
|
|
828
881
|
)
|
|
829
882
|
response = await runner.execute(
|
|
830
883
|
pipe_code=source.pipe_code,
|
|
@@ -916,6 +969,7 @@ async def start(
|
|
|
916
969
|
read_scope=scopes.read_scope,
|
|
917
970
|
extras=extras.analytics_groups,
|
|
918
971
|
library_dirs=source.library_dirs,
|
|
972
|
+
methods_dirs=source.methods_dirs,
|
|
919
973
|
)
|
|
920
974
|
start_result = await runner.start(
|
|
921
975
|
pipe_code=source.pipe_code,
|
|
@@ -71,7 +71,10 @@ class RunRequest(BaseModel):
|
|
|
71
71
|
bundle_b64: PIPELEX-API EXTENSION — base64-encoded zip of the whole method
|
|
72
72
|
bundle (`.mthds` + `.py` + `structures/*.py` + `requirements.txt`). Lets a
|
|
73
73
|
caller ship custom PipeFunc Python alongside the method; materialized into a
|
|
74
|
-
temporary library directory before the run.
|
|
74
|
+
temporary library directory before the run. The method packages the bundle
|
|
75
|
+
calls by address may travel with it under `.mthds/methods/<name>/`, each with
|
|
76
|
+
its `METHODS.toml`, and are searched before the runner's installed copies.
|
|
77
|
+
Mutually exclusive with `files`.
|
|
75
78
|
files: PIPELEX-API EXTENSION — the bundle as a `{relative_path: text}` map (the
|
|
76
79
|
unzipped equivalent of `bundle_b64`). Mutually exclusive with `bundle_b64`.
|
|
77
80
|
method_ref: PIPELEX-API EXTENSION — run a published method by address instead of
|
|
@@ -95,6 +98,8 @@ class RunRequest(BaseModel):
|
|
|
95
98
|
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — base64-encoded zip of the whole "
|
|
96
99
|
"method bundle (`.mthds` + `.py` + `structures/*.py` + `requirements.txt`), materialized into a "
|
|
97
100
|
"temporary library directory before the run so custom PipeFunc Python travels with the method. "
|
|
101
|
+
"The method packages the bundle calls by address may travel with it under `.mthds/methods/<name>/`, "
|
|
102
|
+
"each with its `METHODS.toml`; they are searched before the runner's installed copies, for this run alone. "
|
|
98
103
|
"Mutually exclusive with `files`. Custom `.py` is only honored on a sandbox-hosted deployment."
|
|
99
104
|
),
|
|
100
105
|
)
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
"""Materialize a caller-supplied method bundle into a temporary library directory.
|
|
2
|
-
|
|
3
|
-
A run request may carry the whole method — the `.mthds` bundle plus its PipeFunc
|
|
4
|
-
Python (`pipe_func.py`) and a `requirements.txt` — instead of only
|
|
5
|
-
the inline `mthds_contents` text. Two transport forms are accepted, exactly one
|
|
6
|
-
per request:
|
|
7
|
-
|
|
8
|
-
- `bundle_b64`: a base64-encoded zip archive of the bundle directory.
|
|
9
|
-
- `files`: a `{relative_path: text_content}` map (the zip's contents, unzipped).
|
|
10
|
-
|
|
11
|
-
The two are equivalent: `files` ≡ the zip's entries. This module decodes either
|
|
12
|
-
form, enforces the ingest guards (both transport forms are refused together; a
|
|
13
|
-
hard file-count and total-size ceiling; per-entry path-safety against absolute
|
|
14
|
-
paths and `..` traversal; a zip-bomb guard that bounds actual decompression),
|
|
15
|
-
writes the surviving files into a fresh temp directory, and hands that directory
|
|
16
|
-
back so the runner can load it via `library_dirs`. In a sandbox-hosted
|
|
17
|
-
deployment the load path reads every `.py` as source text, never importing it:
|
|
18
|
-
it refuses a bundle whose Python declares a structure class
|
|
19
|
-
(`MethodStructuresRefusedError`, a 403) and captures the rest onto the crate for
|
|
20
|
-
the sandbox. The caller is responsible for the hosted-mode gate.
|
|
21
|
-
|
|
22
|
-
Nothing here imports or executes the bundle's Python — it only writes bytes to
|
|
23
|
-
disk. The caller cleans the directory up via the `materialized_bundle` context
|
|
24
|
-
manager's guaranteed teardown.
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
from __future__ import annotations
|
|
28
|
-
|
|
29
|
-
import base64
|
|
30
|
-
import binascii
|
|
31
|
-
import shutil
|
|
32
|
-
import tempfile
|
|
33
|
-
import zipfile
|
|
34
|
-
from contextlib import contextmanager
|
|
35
|
-
from io import BytesIO
|
|
36
|
-
from pathlib import Path, PurePosixPath
|
|
37
|
-
from typing import TYPE_CHECKING, NamedTuple
|
|
38
|
-
|
|
39
|
-
from pipelex import log
|
|
40
|
-
from pydantic import ConfigDict
|
|
41
|
-
from pydantic.dataclasses import dataclass
|
|
42
|
-
|
|
43
|
-
from pipelex_api.error_types import ErrorType
|
|
44
|
-
from pipelex_api.errors import raise_bad_request, raise_payload_too_large, raise_validation_error
|
|
45
|
-
from pipelex_api.limits import MAX_BUNDLE_FILES, MAX_BUNDLE_TOTAL_BYTES
|
|
46
|
-
|
|
47
|
-
if TYPE_CHECKING:
|
|
48
|
-
from collections.abc import Generator
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
@dataclass(frozen=True, config=ConfigDict(arbitrary_types_allowed=True))
|
|
52
|
-
class MaterializedBundle:
|
|
53
|
-
"""A bundle written to disk: the directory to load and the relpaths written."""
|
|
54
|
-
|
|
55
|
-
directory: Path
|
|
56
|
-
relpaths: tuple[str, ...]
|
|
57
|
-
|
|
58
|
-
@property
|
|
59
|
-
def has_python_sources(self) -> bool:
|
|
60
|
-
"""True when the bundle ships any `.py` — the trigger for the hosted-mode gate."""
|
|
61
|
-
return any(relpath.endswith(".py") for relpath in self.relpaths)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _safe_relpath(name: str) -> PurePosixPath:
|
|
65
|
-
"""Validate one bundle entry name and return it as a normalized relative POSIX path.
|
|
66
|
-
|
|
67
|
-
Rejects anything that could escape the destination directory: absolute paths,
|
|
68
|
-
Windows drive/backslash forms (a bare drive prefix like `C:foo` has no slash or
|
|
69
|
-
backslash yet is drive-relative on Windows, so `:` is rejected outright), and any
|
|
70
|
-
`..` component. Directory-only entries (trailing slash) return an empty path and
|
|
71
|
-
are filtered by the caller.
|
|
72
|
-
"""
|
|
73
|
-
if not name or name in {".", "./"}:
|
|
74
|
-
return PurePosixPath()
|
|
75
|
-
if "\\" in name:
|
|
76
|
-
msg = f"Bundle entry {name!r} uses backslashes; use forward-slash relative paths only"
|
|
77
|
-
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
78
|
-
if ":" in name:
|
|
79
|
-
msg = f"Bundle entry {name!r} contains ':' (a Windows drive/stream form); use plain relative paths only"
|
|
80
|
-
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
81
|
-
pure = PurePosixPath(name)
|
|
82
|
-
if pure.is_absolute():
|
|
83
|
-
msg = f"Bundle entry {name!r} is an absolute path; only relative paths are allowed"
|
|
84
|
-
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
85
|
-
if any(part == ".." for part in pure.parts):
|
|
86
|
-
msg = f"Bundle entry {name!r} escapes the bundle root via '..'; not allowed"
|
|
87
|
-
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
88
|
-
return pure
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _guard_count(count: int) -> None:
|
|
92
|
-
if count == 0:
|
|
93
|
-
raise_validation_error(message="Bundle is empty (no files)", error_type=ErrorType.INVALID_BUNDLE)
|
|
94
|
-
if count > MAX_BUNDLE_FILES:
|
|
95
|
-
raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_FILES}-file limit (got {count})")
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _guard_running_total(total_bytes: int) -> None:
|
|
99
|
-
if total_bytes > MAX_BUNDLE_TOTAL_BYTES:
|
|
100
|
-
raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB decompressed-size limit")
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
# Base64 inflates by 4/3; a zip is compressed, so a bundle whose DECOMPRESSED content is within
|
|
104
|
-
# the ceiling encodes to well under this. Bounding the base64 string BEFORE decoding stops a
|
|
105
|
-
# ~100 MiB request-body (the only other bound) from being expanded into ~75 MiB of heap just to
|
|
106
|
-
# be rejected later by the decompressed-size guard. Slack (+4) covers padding.
|
|
107
|
-
_MAX_BUNDLE_B64_CHARS = MAX_BUNDLE_TOTAL_BYTES * 4 // 3 + 4
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _entries_from_zip(bundle_b64: str) -> list[tuple[PurePosixPath, bytes]]:
|
|
111
|
-
"""Decode a base64 zip and return its (safe relpath, bytes) file entries.
|
|
112
|
-
|
|
113
|
-
Zip-bomb guard: each member is read through a bounded stream so a lying
|
|
114
|
-
uncompressed-size header cannot force unbounded decompression — the running
|
|
115
|
-
total is checked against `MAX_BUNDLE_TOTAL_BYTES` as bytes are pulled. A cheap
|
|
116
|
-
length check on the still-encoded string runs FIRST, so an oversized payload is
|
|
117
|
-
refused before it is buffered into memory as decoded bytes.
|
|
118
|
-
"""
|
|
119
|
-
if len(bundle_b64) > _MAX_BUNDLE_B64_CHARS:
|
|
120
|
-
raise_payload_too_large(message=f"bundle_b64 exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB compressed-size limit")
|
|
121
|
-
try:
|
|
122
|
-
raw = base64.b64decode(bundle_b64, validate=True)
|
|
123
|
-
except (binascii.Error, ValueError) as decode_error:
|
|
124
|
-
log.warning(f"bundle: invalid base64 ({decode_error})")
|
|
125
|
-
raise_bad_request(message="bundle_b64 is not valid base64", error_type=ErrorType.INVALID_BASE64)
|
|
126
|
-
|
|
127
|
-
try:
|
|
128
|
-
archive = zipfile.ZipFile(BytesIO(raw))
|
|
129
|
-
except zipfile.BadZipFile as zip_error:
|
|
130
|
-
log.warning(f"bundle: corrupt zip ({zip_error})")
|
|
131
|
-
raise_validation_error(message="bundle_b64 is not a valid zip archive", error_type=ErrorType.INVALID_BUNDLE)
|
|
132
|
-
|
|
133
|
-
entries: list[tuple[PurePosixPath, bytes]] = []
|
|
134
|
-
total_bytes = 0
|
|
135
|
-
with archive:
|
|
136
|
-
members = [info for info in archive.infolist() if not info.is_dir()]
|
|
137
|
-
_guard_count(len(members))
|
|
138
|
-
budget = MAX_BUNDLE_TOTAL_BYTES
|
|
139
|
-
for info in members:
|
|
140
|
-
relpath = _safe_relpath(info.filename)
|
|
141
|
-
if not relpath.parts:
|
|
142
|
-
continue
|
|
143
|
-
# Bounded read: pull at most (remaining budget + 1) bytes so a zip bomb whose header
|
|
144
|
-
# under-reports its size still cannot decompress past the ceiling.
|
|
145
|
-
with archive.open(info) as member:
|
|
146
|
-
data = member.read(budget + 1)
|
|
147
|
-
total_bytes += len(data)
|
|
148
|
-
_guard_running_total(total_bytes)
|
|
149
|
-
budget = MAX_BUNDLE_TOTAL_BYTES - total_bytes
|
|
150
|
-
entries.append((relpath, data))
|
|
151
|
-
return entries
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _entries_from_files(files: dict[str, str]) -> list[tuple[PurePosixPath, bytes]]:
|
|
155
|
-
"""Validate a {relpath: text} map and return its (safe relpath, bytes) entries."""
|
|
156
|
-
_guard_count(len(files))
|
|
157
|
-
entries: list[tuple[PurePosixPath, bytes]] = []
|
|
158
|
-
total_bytes = 0
|
|
159
|
-
for name, content in files.items():
|
|
160
|
-
relpath = _safe_relpath(name)
|
|
161
|
-
if not relpath.parts:
|
|
162
|
-
msg = f"Bundle entry {name!r} has no filename"
|
|
163
|
-
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
164
|
-
data = content.encode("utf-8")
|
|
165
|
-
total_bytes += len(data)
|
|
166
|
-
_guard_running_total(total_bytes)
|
|
167
|
-
entries.append((relpath, data))
|
|
168
|
-
return entries
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
class ParsedBundle(NamedTuple):
|
|
172
|
-
"""A decoded-and-validated bundle held in memory, NOT yet written to disk.
|
|
173
|
-
|
|
174
|
-
Splitting parse from materialize lets the caller apply the sandbox-hosted gate
|
|
175
|
-
(which keys on `has_python_sources`) BEFORE any disk write — so a bundle destined
|
|
176
|
-
for a 403 never touches the filesystem. Entries are `(safe relpath, bytes)`.
|
|
177
|
-
"""
|
|
178
|
-
|
|
179
|
-
entries: tuple[tuple[PurePosixPath, bytes], ...]
|
|
180
|
-
|
|
181
|
-
@property
|
|
182
|
-
def has_python_sources(self) -> bool:
|
|
183
|
-
"""True when the bundle ships any `.py` — the trigger for the hosted-mode gate."""
|
|
184
|
-
return any(str(relpath).endswith(".py") for relpath, _ in self.entries)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def parse_bundle(*, bundle_b64: str | None, files: dict[str, str] | None) -> ParsedBundle:
|
|
188
|
-
"""Decode + guard a bundle into an in-memory `ParsedBundle` (no disk writes).
|
|
189
|
-
|
|
190
|
-
Exactly one of `bundle_b64` / `files` must be supplied; supplying both is a
|
|
191
|
-
caller mistake (they are the same content in two forms) and is refused. All
|
|
192
|
-
ingest guards (base64, size, count, path-safety, zip-bomb) run here, so the
|
|
193
|
-
caller can inspect `has_python_sources` and reject BEFORE materializing to disk.
|
|
194
|
-
"""
|
|
195
|
-
if bundle_b64 is not None and files is not None:
|
|
196
|
-
msg = "Provide either bundle_b64 or files, not both"
|
|
197
|
-
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
198
|
-
if bundle_b64 is not None:
|
|
199
|
-
entries = _entries_from_zip(bundle_b64)
|
|
200
|
-
elif files is not None:
|
|
201
|
-
entries = _entries_from_files(files)
|
|
202
|
-
else:
|
|
203
|
-
msg = "No bundle supplied (bundle_b64 and files are both absent)"
|
|
204
|
-
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
205
|
-
return ParsedBundle(entries=tuple(entries))
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
@contextmanager
|
|
209
|
-
def materialize_parsed(parsed: ParsedBundle) -> Generator[MaterializedBundle, None, None]:
|
|
210
|
-
"""Write an already-parsed bundle into a fresh temp directory, cleaned up on exit.
|
|
211
|
-
|
|
212
|
-
The yielded `MaterializedBundle.directory` is safe to pass as a `library_dirs`
|
|
213
|
-
entry; it is removed when the context exits, on both the happy and error path.
|
|
214
|
-
"""
|
|
215
|
-
directory = Path(tempfile.mkdtemp(prefix="pipelex-bundle-"))
|
|
216
|
-
try:
|
|
217
|
-
root = directory.resolve()
|
|
218
|
-
relpaths: list[str] = []
|
|
219
|
-
for relpath, data in parsed.entries:
|
|
220
|
-
target = (directory / relpath).resolve()
|
|
221
|
-
# Defense-in-depth: even after per-part validation, confirm the resolved target stays
|
|
222
|
-
# under the temp root before writing (guards against symlink/edge normalization surprises).
|
|
223
|
-
if root != target and root not in target.parents:
|
|
224
|
-
msg = f"Bundle entry {str(relpath)!r} resolves outside the bundle root"
|
|
225
|
-
raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
|
|
226
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
-
target.write_bytes(data)
|
|
228
|
-
relpaths.append(relpath.as_posix())
|
|
229
|
-
yield MaterializedBundle(directory=directory, relpaths=tuple(relpaths))
|
|
230
|
-
finally:
|
|
231
|
-
shutil.rmtree(directory, ignore_errors=True)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
@contextmanager
|
|
235
|
-
def materialized_bundle(*, bundle_b64: str | None, files: dict[str, str] | None) -> Generator[MaterializedBundle, None, None]:
|
|
236
|
-
"""Parse AND materialize a bundle in one step (convenience for callers that don't gate).
|
|
237
|
-
|
|
238
|
-
Equivalent to `parse_bundle(...)` followed by `materialize_parsed(...)`; a caller
|
|
239
|
-
that must apply the sandbox-hosted gate before disk writes should use the two
|
|
240
|
-
steps directly instead.
|
|
241
|
-
"""
|
|
242
|
-
with materialize_parsed(parse_bundle(bundle_b64=bundle_b64, files=files)) as bundle:
|
|
243
|
-
yield bundle
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|