pipelex-api 0.86.0__tar.gz → 0.87.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.
Files changed (51) hide show
  1. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/PKG-INFO +2 -2
  2. pipelex_api-0.87.0/pipelex_api/blob_store.py +190 -0
  3. pipelex_api-0.87.0/pipelex_api/blob_upload.py +102 -0
  4. pipelex_api-0.87.0/pipelex_api/bundle.py +87 -0
  5. pipelex_api-0.87.0/pipelex_api/bundle_resources.py +84 -0
  6. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/error_types.py +27 -2
  7. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/errors.py +14 -0
  8. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/exception_handlers.py +0 -5
  9. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/limits.py +19 -1
  10. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/main.py +6 -0
  11. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/method_cache.py +59 -12
  12. pipelex_api-0.87.0/pipelex_api/method_source.py +365 -0
  13. pipelex_api-0.87.0/pipelex_api/method_tree_request.py +727 -0
  14. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/openapi_responses.py +6 -0
  15. pipelex_api-0.87.0/pipelex_api/routes/blob_upload.py +71 -0
  16. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/__init__.py +2 -0
  17. pipelex_api-0.87.0/pipelex_api/routes/pipelex/blobs.py +143 -0
  18. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/codegen.py +3 -3
  19. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/crate_ops.py +7 -5
  20. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/pipe_io.py +1 -1
  21. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/pipeline.py +192 -182
  22. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/resolve.py +1 -1
  23. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/validate.py +125 -101
  24. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/version.py +4 -0
  25. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/schemas/models.py +95 -40
  26. pipelex_api-0.86.0/pipelex_api/bundle.py +0 -436
  27. pipelex_api-0.86.0/pipelex_api/bundle_resources.py +0 -148
  28. pipelex_api-0.86.0/pipelex_api/method_source.py +0 -248
  29. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/.gitignore +0 -0
  30. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/LICENSE +0 -0
  31. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/README.md +0 -0
  32. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/hatch_build.py +0 -0
  33. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/__init__.py +0 -0
  34. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/api.toml +0 -0
  35. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/api_config.py +0 -0
  36. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/disclosure.py +0 -0
  37. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/error_uri.py +0 -0
  38. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/json_body.py +0 -0
  39. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/middleware.py +0 -0
  40. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/openapi_schema.py +0 -0
  41. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/problem_document.py +0 -0
  42. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/py.typed +0 -0
  43. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/__init__.py +0 -0
  44. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/health.py +0 -0
  45. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
  46. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/agent/models.py +0 -0
  47. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/tools.py +0 -0
  48. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/utils.py +0 -0
  49. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/schemas/__init__.py +0 -0
  50. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pipelex_api/security.py +0 -0
  51. {pipelex_api-0.86.0 → pipelex_api-0.87.0}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pipelex-api
3
- Version: 0.86.0
3
+ Version: 0.87.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.86.0
19
+ Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.87.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,190 @@
1
+ """The runner's blob store: bytes keyed by their SHA-256, and the listings of the trees it holds in full.
2
+
3
+ A sender asks which of its hashes the runner lacks (`POST /v1/blobs/missing`), uploads only those
4
+ (`PUT /v1/blobs/{sha256}`), then sends its method as a map of file entries naming them by hash. This runner keeps those
5
+ bytes in its configured storage provider, local or cloud, so a self-hosted deployment gets the shape the hosted plane
6
+ has (`wip/extra-files/design.md`, D5 and D6), under one prefix per caller:
7
+
8
+ <scope>/blobs/<sha256> a file's bytes, written once and never changed
9
+ <scope>/trees/<hex> a tree's listing, under the hexadecimal part of its digest
10
+
11
+ The scope is the run's trusted read scope, the one a multi-tenant host sends for the organization it serves, or else the
12
+ caller's own id (`single-tenant` on a deployment that identifies no caller): a hash entry resolves only in the caller's
13
+ own scope, never across tenants, so asking whether a hash is held tells a caller nothing of another's files.
14
+
15
+ A listing is written only after every file it names is held, by this runner once it has read a request's tree in
16
+ full, and never by an upload: `PUT /v1/blobs/{sha256}` writes under `blobs/` alone, so an uploaded file that happens to
17
+ be a well-formed listing proves nothing. A listing present means the whole tree is present, which lets
18
+ `POST /v1/blobs/missing` answer a tree's digest with one lookup.
19
+
20
+ Every object under `blobs/<sha256>` holds, whole, the bytes whose SHA-256 is that hash: the key is the hash this runner
21
+ computes from the bytes it writes, the runner's own `PUT /v1/blobs/{sha256}` writes only bytes it checked against the
22
+ path's hash, and every built-in storage provider publishes a write whole or not at all (the local one through a
23
+ temporary file renamed onto the key). So an object's stored size, read from its metadata without reading its bytes, is
24
+ the size of that hash's bytes, and nothing deletes or rewrites a blob. A listing, written once each of its files was
25
+ held at its size, therefore stays true.
26
+
27
+ From this the blob store answers without reading what it does not need:
28
+
29
+ - `POST /v1/blobs/missing` lists a hash this runner does not hold at the size the sender states, an object held at
30
+ another size included, which only a storage that does not publish writes whole could leave short, and which the
31
+ upload then writes over.
32
+ - A run looks up each hash's stored size first. A hash held at another size than an entry states is a 422
33
+ `BlobMismatch`, never `MissingBlobs`, since uploading those bytes again would change nothing: the entry is wrong.
34
+ Only a blob held at the size its entries state is read, so the bytes read are bounded by the sizes the request
35
+ states, which its census already held to the tree's limits, and each is checked against its hash once read. Bytes
36
+ that do not hash to their key mean the storage itself is damaged, which no caller can repair: a 500
37
+ `BlobStoreCorrupted`.
38
+
39
+ Lookups and reads run a bounded number at a time, so one request naming many blobs never opens them all at once.
40
+ """
41
+
42
+ from collections.abc import Iterable, Mapping
43
+ from functools import partial
44
+
45
+ from mthds.method_tree.digest import DIGEST_PREFIX, sha256_hex, tree_listing
46
+ from mthds.method_tree.file_entry import BlobStore, InMemoryBlobStore
47
+ from mthds.method_tree.models import MethodTree
48
+ from pipelex.core.method_trees.exceptions import MethodFileUnavailableError
49
+ from pipelex.core.method_trees.method_files import TreeListing
50
+ from pipelex.runtime_hub import get_storage_provider
51
+ from pipelex.tools.misc.async_utils import gather_bounded
52
+ from pipelex.tools.storage.exceptions import StorageFileNotFoundError
53
+ from pipelex.tools.storage.storage_provider_abstract import PIPELEX_STORAGE_SCHEME, StorageProviderAbstract
54
+
55
+ from pipelex_api.error_types import ErrorType
56
+ from pipelex_api.errors import raise_internal_server_error
57
+
58
+ BLOBS_PREFIX = "blobs"
59
+ TREES_PREFIX = "trees"
60
+ _BLOB_CONTENT_TYPE = "application/octet-stream"
61
+ _LISTING_CONTENT_TYPE = "text/plain; charset=utf-8"
62
+ # How many lookups of a blob's size, which read metadata alone, and how many reads or writes of a blob's bytes, one
63
+ # request runs at a time.
64
+ MAX_CONCURRENT_BLOB_LOOKUPS = 16
65
+ MAX_CONCURRENT_BLOB_TRANSFERS = 4
66
+
67
+
68
+ class RunnerBlobStore:
69
+ """The blobs and tree listings of one caller's scope, over the deployment's storage provider."""
70
+
71
+ def __init__(self, *, scope: str, storage: StorageProviderAbstract) -> None:
72
+ self._scope = scope
73
+ self._storage = storage
74
+
75
+ @classmethod
76
+ def make_for_scope(cls, *, scope: str) -> "RunnerBlobStore":
77
+ """The blob store of `scope`, over the storage provider the runtime booted with."""
78
+ return cls(scope=scope, storage=get_storage_provider())
79
+
80
+ @property
81
+ def scope(self) -> str:
82
+ return self._scope
83
+
84
+ def _blob_key(self, *, sha256: str) -> str:
85
+ return f"{self._scope}/{BLOBS_PREFIX}/{sha256}"
86
+
87
+ def _tree_key(self, *, tree_digest: str) -> str:
88
+ return f"{self._scope}/{TREES_PREFIX}/{tree_digest.removeprefix(DIGEST_PREFIX)}"
89
+
90
+ async def _holds_key(self, *, key: str) -> bool:
91
+ try:
92
+ await self._storage.load_size(f"{PIPELEX_STORAGE_SCHEME}{key}")
93
+ except StorageFileNotFoundError:
94
+ return False
95
+ return True
96
+
97
+ async def stored_size(self, *, sha256: str) -> int | None:
98
+ """The size of the object stored under the hash `sha256`, read from its metadata, or None when there is none."""
99
+ try:
100
+ return await self._storage.load_size(f"{PIPELEX_STORAGE_SCHEME}{self._blob_key(sha256=sha256)}")
101
+ except StorageFileNotFoundError:
102
+ return None
103
+
104
+ async def holds_blob(self, *, sha256: str, size: int) -> bool:
105
+ """Whether the bytes whose SHA-256 is `sha256` are held at `size`, an object of another size being no blob."""
106
+ return await self.stored_size(sha256=sha256) == size
107
+
108
+ async def stored_sizes(self, *, sha256s: Iterable[str]) -> dict[str, int | None]:
109
+ """Each hash's stored size, None for a hash not held, looked up a bounded number at a time."""
110
+ hashes = list(sha256s)
111
+ sizes = await gather_bounded([partial(self.stored_size, sha256=sha256) for sha256 in hashes], max_concurrency=MAX_CONCURRENT_BLOB_LOOKUPS)
112
+ return dict(zip(hashes, sizes, strict=True))
113
+
114
+ async def _read_verified(self, *, sha256: str, size: int) -> bytes:
115
+ """The stored bytes of `sha256`, held at `size`, checked against their hash.
116
+
117
+ Raises (as a problem document): a 500 `BlobStoreCorrupted` for bytes that are not `size` bytes hashing to `sha256`.
118
+ """
119
+ data = await self._storage.load(f"{PIPELEX_STORAGE_SCHEME}{self._blob_key(sha256=sha256)}")
120
+ if len(data) != size or sha256_hex(data) != sha256:
121
+ msg = (
122
+ f"The bytes this runner's blob store holds under {sha256} do not hash to it: its storage is damaged, "
123
+ "and its operator must remove that object for the blob to be uploaded again."
124
+ )
125
+ raise_internal_server_error(message=msg, error_type=ErrorType.BLOB_STORE_CORRUPTED)
126
+ return data
127
+
128
+ async def read_held(self, *, sizes: Mapping[str, int]) -> BlobStore:
129
+ """Read these held blobs, each at its stored size, as the synchronous blob store a map of file entries is read with.
130
+
131
+ Args:
132
+ sizes: Each hash to read, with the size it is held at, which the caller bounded before asking.
133
+ """
134
+ blobs = await gather_bounded(
135
+ [partial(self._read_verified, sha256=sha256, size=size) for sha256, size in sizes.items()],
136
+ max_concurrency=MAX_CONCURRENT_BLOB_TRANSFERS,
137
+ )
138
+ return InMemoryBlobStore(blobs)
139
+
140
+ async def store_blob(self, *, data: bytes) -> str:
141
+ """Hold `data` under its SHA-256, writing it unless it is held at its size already, and return the hash.
142
+
143
+ An object under the hash at another size, which only a storage that does not publish writes whole could leave,
144
+ is written over.
145
+ """
146
+ sha256 = sha256_hex(data)
147
+ if not await self.holds_blob(sha256=sha256, size=len(data)):
148
+ await self._storage.store(data, key=self._blob_key(sha256=sha256), content_type=_BLOB_CONTENT_TYPE)
149
+ return sha256
150
+
151
+ async def holds_tree(self, *, tree_digest: str) -> bool:
152
+ """Whether the listing of the tree `tree_digest` names is held, which means every file of the tree is."""
153
+ return await self._holds_key(key=self._tree_key(tree_digest=tree_digest))
154
+
155
+ async def tree_hashes(self, *, tree_digest: str) -> set[str] | None:
156
+ """The hashes of the files of the tree `tree_digest` names, when its listing is held, else None.
157
+
158
+ The listing is read and checked against the digest, so a sender naming a tree it does not send learns nothing
159
+ held that its own blobs are not part of.
160
+ """
161
+ try:
162
+ listing = await self._storage.load(f"{PIPELEX_STORAGE_SCHEME}{self._tree_key(tree_digest=tree_digest)}")
163
+ except StorageFileNotFoundError:
164
+ return None
165
+ try:
166
+ tree_listing_read = TreeListing.parse(tree_digest=tree_digest, listing=listing)
167
+ except MethodFileUnavailableError:
168
+ # Not the listing the digest names: the tree is not held.
169
+ return None
170
+ return set(tree_listing_read.files.values())
171
+
172
+ async def record_tree(self, *, tree: MethodTree, contents: Mapping[str, bytes]) -> None:
173
+ """Hold every file of a tree read in full, then its listing, so a later request can send it by hash.
174
+
175
+ The listing is written last, once every file it names is held, so a listing present always means its whole
176
+ tree is, and a tree whose listing is held already is left as it is, without a lookup per file.
177
+
178
+ Args:
179
+ tree: The tree, as a map of file entries made it.
180
+ contents: Each file's bytes by tree path.
181
+ """
182
+ if await self.holds_tree(tree_digest=tree.digest):
183
+ return
184
+ await gather_bounded([partial(self.store_blob, data=data) for data in contents.values()], max_concurrency=MAX_CONCURRENT_BLOB_TRANSFERS)
185
+ await self._storage.store(tree_listing(tree.files), key=self._tree_key(tree_digest=tree.digest), content_type=_LISTING_CONTENT_TYPE)
186
+
187
+ async def missing_blobs(self, *, sizes: Mapping[str, int]) -> list[str]:
188
+ """The hashes of `sizes` not held at their size, in the mapping's order."""
189
+ stored = await self.stored_sizes(sha256s=sizes)
190
+ return [sha256 for sha256, size in sizes.items() if stored[sha256] != size]
@@ -0,0 +1,102 @@
1
+ """The signed upload targets `POST /v1/blobs/missing` names, and their check on `PUT /v1/blobs/{sha256}`.
2
+
3
+ A sender uploads a blob to the target the runner named, carrying exactly the headers it named, so the target itself
4
+ must say that the upload is allowed, as a presigned URL does on an object store. This runner signs each target it
5
+ names: its URL is its own `PUT /v1/blobs/{sha256}` with a query naming the blob store's scope, an expiry and an
6
+ HMAC-SHA256 signature over the scope, the hash and the expiry. The upload route checks the signature and the expiry,
7
+ writes under the scope the signature binds and nowhere else, and answers a missing, forged or expired signature with a
8
+ 403 `BlobUploadNotAuthorized`. The upload route therefore needs no bearer token: only a caller who passed the
9
+ deployment's authentication on `POST /v1/blobs/missing` holds a target, for one hash, in its own scope, for a while.
10
+
11
+ The key is `BLOB_UPLOAD_SIGNING_KEY` when the deployment sets it, which it must when several instances serve one
12
+ deployment, since a target named by one instance is checked by whichever instance receives the upload; otherwise each
13
+ process draws a random key of its own at its first signature.
14
+
15
+ A target's URL starts with `PUBLIC_BASE_URL` when the deployment sets it, the address its clients reach the API at,
16
+ such as `https://runner.example.com`: behind a proxy that terminates TLS or serves the API under a path prefix, the URL
17
+ the runner received a request at is not the one a client can upload to. Unset, the target starts with the URL of the
18
+ request that asked for it.
19
+ """
20
+
21
+ import hashlib
22
+ import hmac
23
+ import secrets
24
+ import time
25
+ from functools import cache
26
+ from urllib.parse import urlencode, urlsplit
27
+
28
+ from fastapi import Request
29
+ from pipelex.system.environment import get_optional_env
30
+
31
+ from pipelex_api.error_types import ErrorType
32
+ from pipelex_api.errors import raise_internal_server_error
33
+ from pipelex_api.limits import BLOB_UPLOAD_URL_TTL_SECONDS
34
+
35
+ PUBLIC_BASE_URL_ENV = "PUBLIC_BASE_URL"
36
+ _UPLOAD_ROUTE_NAME = "put_blob"
37
+
38
+ SCOPE_PARAM = "scope"
39
+ EXPIRES_PARAM = "expires"
40
+ SIGNATURE_PARAM = "signature"
41
+
42
+
43
+ @cache
44
+ def _signing_key() -> bytes:
45
+ configured = get_optional_env("BLOB_UPLOAD_SIGNING_KEY")
46
+ if configured:
47
+ return configured.encode("utf-8")
48
+ return secrets.token_bytes(32)
49
+
50
+
51
+ def _signature(*, scope: str, sha256: str, expires: int) -> str:
52
+ message = f"{scope}\n{sha256}\n{expires}".encode()
53
+ return hmac.new(_signing_key(), message, hashlib.sha256).hexdigest()
54
+
55
+
56
+ def signed_upload_query(*, scope: str, sha256: str) -> str:
57
+ """The query string of an upload target for one blob in one scope, valid for `BLOB_UPLOAD_URL_TTL_SECONDS`."""
58
+ expires = int(time.time()) + BLOB_UPLOAD_URL_TTL_SECONDS
59
+ return urlencode({SCOPE_PARAM: scope, EXPIRES_PARAM: expires, SIGNATURE_PARAM: _signature(scope=scope, sha256=sha256, expires=expires)})
60
+
61
+
62
+ def _public_base_url() -> str | None:
63
+ """`PUBLIC_BASE_URL` without its trailing slash, or None when unset.
64
+
65
+ Raises (as a problem document): a 500 `ServerMisconfigured` for a value that is not an absolute http or https URL
66
+ without a query or a fragment.
67
+ """
68
+ configured = get_optional_env(PUBLIC_BASE_URL_ENV)
69
+ if not configured:
70
+ return None
71
+ parts = urlsplit(configured)
72
+ if parts.scheme not in {"http", "https"} or not parts.netloc or parts.query or parts.fragment:
73
+ msg = f"Server configuration error: {PUBLIC_BASE_URL_ENV} must be an absolute http or https URL, such as https://runner.example.com."
74
+ raise_internal_server_error(message=msg, error_type=ErrorType.SERVER_MISCONFIGURED)
75
+ return configured.rstrip("/")
76
+
77
+
78
+ def upload_target_url(*, request: Request, scope: str, sha256: str) -> str:
79
+ """The signed URL a sender uploads one blob to: this runner's `PUT /v1/blobs/{sha256}`, at its public address."""
80
+ query = signed_upload_query(scope=scope, sha256=sha256)
81
+ base_url = _public_base_url()
82
+ if base_url is None:
83
+ return f"{request.url_for(_UPLOAD_ROUTE_NAME, sha256=sha256)}?{query}"
84
+ return f"{base_url}{request.app.url_path_for(_UPLOAD_ROUTE_NAME, sha256=sha256)}?{query}"
85
+
86
+
87
+ def is_upload_authorized(*, scope: str | None, sha256: str, expires: str | None, signature: str | None) -> bool:
88
+ """Whether an upload carries a signature this runner made for this hash and scope, still within its expiry.
89
+
90
+ Every part of the query is the caller's text, read as such: an expiry that is not a decimal integer, or a signature
91
+ holding characters no signature this runner makes holds, is no valid target, never a failure of the check.
92
+ """
93
+ if scope is None or expires is None or signature is None:
94
+ return False
95
+ if not expires.isascii() or not expires.isdecimal():
96
+ return False
97
+ expires_at = int(expires)
98
+ if expires_at < int(time.time()):
99
+ return False
100
+ # Compared as bytes: `compare_digest` refuses text holding a character outside ASCII, which a forged signature may.
101
+ expected = _signature(scope=scope, sha256=sha256, expires=expires_at).encode("ascii")
102
+ return hmac.compare_digest(expected, signature.encode("utf-8", "surrogatepass"))
@@ -0,0 +1,87 @@
1
+ """Decode a `bundle_b64` archive into its entries, under the guards a zip archive needs.
2
+
3
+ A run request may carry its method as `bundle_b64`, a base64-encoded zip archive of the method's folder, beside the
4
+ map of file entries `files` that replaces it. This module only opens the archive: it bounds the encoded string before
5
+ decoding it, refuses an archive with too many members, and reads each member through a bounded stream so a zip bomb
6
+ whose header under-reports its size cannot decompress past the ceiling. The entries it returns keep the names the
7
+ archive gives them; `pipelex_api.method_tree_request` reads them as a map of file entries, so the archive's names are
8
+ judged by the one rule of the method tree, as the keys of `files` are: a name that is not a tree path, or that names a
9
+ file the membership rule keeps out of every tree, is refused, and so is one name given twice.
10
+
11
+ Nothing here writes to disk, imports or executes the archive's Python.
12
+ """
13
+
14
+ import base64
15
+ import binascii
16
+ import zipfile
17
+ from io import BytesIO
18
+
19
+ from pipelex import log
20
+ from pipelex.tools.log.error_fields import error_fields
21
+
22
+ from pipelex_api.error_types import ErrorType
23
+ from pipelex_api.errors import raise_bad_request, raise_payload_too_large, raise_validation_error
24
+ from pipelex_api.limits import MAX_BUNDLE_FILES, MAX_BUNDLE_TOTAL_BYTES
25
+
26
+ # Base64 inflates by 4/3; a zip is compressed, so a bundle whose DECOMPRESSED content is within
27
+ # the ceiling encodes to well under this. Bounding the base64 string BEFORE decoding stops a
28
+ # ~100 MiB request-body (the only other bound) from being expanded into ~75 MiB of heap just to
29
+ # be rejected later by the decompressed-size guard. Slack (+4) covers padding.
30
+ _MAX_BUNDLE_B64_CHARS = MAX_BUNDLE_TOTAL_BYTES * 4 // 3 + 4
31
+
32
+
33
+ def _guard_count(count: int) -> None:
34
+ if count == 0:
35
+ raise_validation_error(message="Bundle is empty (no files)", error_type=ErrorType.INVALID_BUNDLE)
36
+ if count > MAX_BUNDLE_FILES:
37
+ raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_FILES}-file limit (got {count})")
38
+
39
+
40
+ def _guard_running_total(total_bytes: int) -> None:
41
+ if total_bytes > MAX_BUNDLE_TOTAL_BYTES:
42
+ raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB decompressed-size limit")
43
+
44
+
45
+ def decode_bundle_archive(*, bundle_b64: str) -> list[tuple[str, bytes]]:
46
+ """Decode a base64 zip and return its file members as `(name, bytes)`, in archive order, directories skipped.
47
+
48
+ Zip-bomb guard: each member is read through a bounded stream so a lying uncompressed-size header cannot force
49
+ unbounded decompression — the running total is checked against `MAX_BUNDLE_TOTAL_BYTES` as bytes are pulled. A
50
+ cheap length check on the still-encoded string runs FIRST, so an oversized payload is refused before it is buffered
51
+ into memory as decoded bytes. A name the archive gives twice is returned twice, for the tree's reader to refuse.
52
+
53
+ Raises (as problem documents): an oversized archive (413 `PayloadTooLarge`), a value that is not base64 (400
54
+ `InvalidBase64`), and one that is not a zip archive or holds no file (422 `InvalidBundle`).
55
+ """
56
+ if len(bundle_b64) > _MAX_BUNDLE_B64_CHARS:
57
+ raise_payload_too_large(message=f"bundle_b64 exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB compressed-size limit")
58
+ try:
59
+ raw = base64.b64decode(bundle_b64, validate=True)
60
+ except (binascii.Error, ValueError) as decode_error:
61
+ # DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
62
+ log.debug("A bundle's base64 could not be decoded", fields=error_fields(exc=decode_error))
63
+ raise_bad_request(message="bundle_b64 is not valid base64", error_type=ErrorType.INVALID_BASE64)
64
+
65
+ try:
66
+ archive = zipfile.ZipFile(BytesIO(raw))
67
+ except zipfile.BadZipFile as zip_error:
68
+ # DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
69
+ log.debug("A bundle is not a valid zip archive", fields=error_fields(exc=zip_error))
70
+ raise_validation_error(message="bundle_b64 is not a valid zip archive", error_type=ErrorType.INVALID_BUNDLE)
71
+
72
+ entries: list[tuple[str, bytes]] = []
73
+ total_bytes = 0
74
+ with archive:
75
+ members = [info for info in archive.infolist() if not info.is_dir()]
76
+ _guard_count(len(members))
77
+ budget = MAX_BUNDLE_TOTAL_BYTES
78
+ for info in members:
79
+ # Bounded read: pull at most (remaining budget + 1) bytes so a zip bomb whose header
80
+ # under-reports its size still cannot decompress past the ceiling.
81
+ with archive.open(info) as member:
82
+ data = member.read(budget + 1)
83
+ total_bytes += len(data)
84
+ _guard_running_total(total_bytes)
85
+ budget = MAX_BUNDLE_TOTAL_BYTES - total_bytes
86
+ entries.append((info.filename, data))
87
+ return entries
@@ -0,0 +1,84 @@
1
+ """Decode the files a request sends in `resources`, the files its bundle's steps read, such as a template file.
2
+
3
+ `resources` is a JSON object mapping each file's bundle-relative path, the exact path a step names (a `PipeDocGen`
4
+ step's `template_file`), to the file's bytes in standard base64. Beside `mthds_contents`, every bundle of the request is
5
+ read as sitting in one directory, so one path holds one file, and the decoded files go to the runtime as its
6
+ `submitted_files`, keyed by canonical path, which refuses a file none of the bundle's steps names once the bundle is
7
+ parsed, and holds the rest as the files of the tree the bundle sits at the root of. Beside a method tree (`files` or
8
+ `bundle_b64`), the decoded files join the tree at their paths from its root (`pipelex_api.method_tree_request`), where
9
+ the tree's rule judges them as it judges the tree's own files.
10
+
11
+ Refusals, in the order they are checked:
12
+
13
+ - more `resources` than `MAX_RESOURCES_PER_REQUEST` → 413 `PayloadTooLarge`;
14
+ - a path that cannot name a file inside the bundle (absolute, a `..` segment, a backslash, a colon, empty, or a
15
+ second spelling of a path already submitted) → 422 `InvalidResourcePath`, before any value is decoded;
16
+ - a file whose encoded length already exceeds the per-file ceiling → 413 `PayloadTooLarge`, before decoding;
17
+ - a value that is not valid base64 → 400 `InvalidBase64`, as `bundle_b64` is;
18
+ - a decoded file over `MAX_RESOURCE_FILE_BYTES`, or a running total over `MAX_RESOURCES_TOTAL_BYTES` → 413.
19
+ """
20
+
21
+ import base64
22
+ import binascii
23
+ from typing import NoReturn
24
+
25
+ from pipelex import log
26
+ from pipelex.core.bundle_resources.bundle_resource import canonical_resource_path
27
+ from pipelex.core.bundle_resources.exceptions import BundleResourcePathError
28
+ from pipelex.tools.log.error_fields import error_fields
29
+
30
+ from pipelex_api.error_types import ErrorType
31
+ from pipelex_api.errors import raise_bad_request, raise_payload_too_large, raise_validation_error
32
+ from pipelex_api.limits import MAX_RESOURCE_FILE_BYTES, MAX_RESOURCES_PER_REQUEST, MAX_RESOURCES_TOTAL_BYTES
33
+
34
+ # Base64 inflates by 4/3, so a file within the ceiling encodes to at most this many characters (+4 covers padding).
35
+ # Checking the encoded length first refuses an oversized file before it is decoded into memory.
36
+ _MAX_RESOURCE_B64_CHARS = MAX_RESOURCE_FILE_BYTES * 4 // 3 + 4
37
+
38
+
39
+ def refuse_two_files_under_one_path(*, path: str, where: str) -> NoReturn:
40
+ """Refuse two different files sent under one path, naming the path, with a 422 `InvalidResourcePath`."""
41
+ msg = (
42
+ f"Two different files are named '{path}', {where}. A path names one file of a method: send one of them, or rename "
43
+ "one and the step that reads it."
44
+ )
45
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_RESOURCE_PATH)
46
+
47
+
48
+ def decode_bundle_resources(*, resources: dict[str, str] | None) -> dict[str, bytes] | None:
49
+ """The files `resources` sends, decoded and keyed by canonical path, or None when there are none."""
50
+ if not resources:
51
+ return None
52
+ if len(resources) > MAX_RESOURCES_PER_REQUEST:
53
+ raise_payload_too_large(message=f"resources exceeds the {MAX_RESOURCES_PER_REQUEST}-file limit (got {len(resources)})")
54
+ # Every path first, so a second spelling of one file is refused as such before any value is decoded.
55
+ sent_by_path: dict[str, tuple[str, str]] = {}
56
+ for path, encoded in resources.items():
57
+ try:
58
+ canonical_path = canonical_resource_path(path=path)
59
+ except BundleResourcePathError as exc:
60
+ raise_validation_error(message=exc.message, error_type=ErrorType.INVALID_RESOURCE_PATH)
61
+ if canonical_path in sent_by_path:
62
+ msg = f"Two files submitted with the bundle name the same file '{canonical_path}'."
63
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_RESOURCE_PATH)
64
+ sent_by_path[canonical_path] = (path, encoded)
65
+ files: dict[str, bytes] = {}
66
+ total_bytes = 0
67
+ for canonical_path, (path, encoded) in sent_by_path.items():
68
+ if len(encoded) > _MAX_RESOURCE_B64_CHARS:
69
+ raise_payload_too_large(message=f"The file '{path}' in resources exceeds the {MAX_RESOURCE_FILE_BYTES // 1024} KiB per-file limit")
70
+ try:
71
+ data = base64.b64decode(encoded, validate=True)
72
+ except (binascii.Error, ValueError) as decode_error:
73
+ # DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
74
+ log.debug("A submitted file's base64 could not be decoded", fields=error_fields(exc=decode_error))
75
+ raise_bad_request(message=f"The file '{path}' in resources is not valid base64", error_type=ErrorType.INVALID_BASE64)
76
+ if len(data) > MAX_RESOURCE_FILE_BYTES:
77
+ raise_payload_too_large(
78
+ message=f"The file '{canonical_path}' a step reads exceeds the {MAX_RESOURCE_FILE_BYTES // 1024} KiB per-file limit"
79
+ )
80
+ total_bytes += len(data)
81
+ if total_bytes > MAX_RESOURCES_TOTAL_BYTES:
82
+ raise_payload_too_large(message=f"The files the bundle's steps read exceed the {MAX_RESOURCES_TOTAL_BYTES // 1024} KiB total-size limit")
83
+ files[canonical_path] = data
84
+ return files
@@ -58,9 +58,34 @@ class ErrorType(StrEnum):
58
58
  INVALID_MODEL_REFERENCE = "InvalidModelReference"
59
59
  INVALID_BASE64 = "InvalidBase64"
60
60
  PAYLOAD_TOO_LARGE = "PayloadTooLarge"
61
- # A run request carried a malformed method bundle (`bundle_b64` / `files`): a corrupt zip, an
62
- # unsafe entry name (absolute path or `..` traversal), or both transport forms supplied at once.
61
+ # A run request carried a malformed method bundle (`bundle_b64` / `files`): a corrupt zip, a
62
+ # bundle with no `.mthds` file, or both transport forms supplied at once.
63
63
  INVALID_BUNDLE = "InvalidBundle"
64
+ # A request's map of file entries (`files`, a tree of `dependencies`, a `bundle_b64` archive read as
65
+ # one) is not a method tree the MTHDS standard admits: a key given twice, a key that is not a tree
66
+ # path or names a file the membership rule keeps out of every tree, keys that collide, an empty map,
67
+ # or a malformed entry. A 422 whose `tree_refusal` member carries the standard's refusal code and
68
+ # the paths it names.
69
+ INVALID_METHOD_FILES = "InvalidMethodFiles"
70
+ # A request names, by hash, bytes this runner's blob store does not hold at the stated size. A 422
71
+ # whose `missing_blobs` member lists every such hash at once, so a sender learns in one answer what to
72
+ # upload (`POST /v1/blobs/missing`, then `PUT /v1/blobs/{sha256}`).
73
+ MISSING_BLOBS = "MissingBlobs"
74
+ # A method tree, or a blob, passes one of this runner's limits on a tree's file count, its total
75
+ # size or one file's size (`MAX_METHOD_TREE_FILES`, `MAX_METHOD_TREE_TOTAL_KIB`,
76
+ # `MAX_METHOD_TREE_FILE_KIB`). A 422 naming the limit, refused before any blob is read or uploaded.
77
+ METHOD_TREE_TOO_LARGE = "MethodTreeTooLarge"
78
+ # `PUT /v1/blobs/{sha256}` was sent bytes whose SHA-256 is not the one its path names,
79
+ # `POST /v1/blobs/missing` named one hash twice with different sizes, or a request's hash entry states a size other
80
+ # than the one this runner holds that hash's bytes at, which uploading them again would not change. A 422 whose
81
+ # `mismatched_blobs` member, on a request's entries, lists each such hash with the size held.
82
+ BLOB_MISMATCH = "BlobMismatch"
83
+ # The bytes this runner's blob store holds under a hash do not hash to it: the storage is damaged. A 500 no caller
84
+ # can repair; the operator removes the object, and the blob is then uploaded again.
85
+ BLOB_STORE_CORRUPTED = "BlobStoreCorrupted"
86
+ # `PUT /v1/blobs/{sha256}` was called without the signed query `POST /v1/blobs/missing` names in its upload
87
+ # target, with one whose signature does not hold, or past its expiry. A 403: the upload is not stored.
88
+ BLOB_UPLOAD_NOT_AUTHORIZED = "BlobUploadNotAuthorized"
64
89
  # A request's `resources` map keys a submitted file by a path that cannot name a file inside the
65
90
  # bundle: absolute, with a `..` segment, a backslash or a colon, empty, or a second spelling of a
66
91
  # path already submitted. A 422: the key is the path a step names, relative to the bundle.
@@ -15,6 +15,7 @@ are the single translation point for everything the API does not author
15
15
  itself.
16
16
  """
17
17
 
18
+ from collections.abc import Mapping
18
19
  from typing import Any, NoReturn
19
20
 
20
21
  from pipelex.base_exceptions import ErrorDomain
@@ -49,6 +50,7 @@ def _raise_api_error(
49
50
  status: int,
50
51
  error_domain: ErrorDomain,
51
52
  headers: dict[str, str] | None = None,
53
+ members: Mapping[str, Any] | None = None,
52
54
  ) -> NoReturn:
53
55
  """Build the RFC 7807 document and raise `ApiError`.
54
56
 
@@ -67,6 +69,9 @@ def _raise_api_error(
67
69
  request_id=None,
68
70
  error_domain=error_domain,
69
71
  )
72
+ if members:
73
+ # Extension members of RFC 7807 (section 3.2), such as `missing_blobs`: data a client acts on, beside the prose.
74
+ document.update(members)
70
75
  raise ApiError(status_code=status, document=document, headers=headers)
71
76
 
72
77
 
@@ -75,6 +80,15 @@ def raise_validation_error(message: str, error_type: ErrorType = ErrorType.VALID
75
80
  _raise_api_error(error_type=error_type, message=message, status=422, error_domain=ErrorDomain.INPUT)
76
81
 
77
82
 
83
+ def raise_validation_problem(*, message: str, error_type: ErrorType, members: Mapping[str, Any]) -> NoReturn:
84
+ """Raise a 422 RFC 7807 problem response for invalid caller input, carrying extension members a client acts on.
85
+
86
+ The members join the document beside `detail`, such as the `missing_blobs` a sender uploads before it sends the
87
+ request again, or the `tree_refusal` naming the standard's refusal code and the paths it names.
88
+ """
89
+ _raise_api_error(error_type=error_type, message=message, status=422, error_domain=ErrorDomain.INPUT, members=members)
90
+
91
+
78
92
  def raise_bad_request(message: str, error_type: ErrorType = ErrorType.BAD_REQUEST) -> NoReturn:
79
93
  """Raise a 400 RFC 7807 problem response for a malformed request."""
80
94
  _raise_api_error(error_type=error_type, message=message, status=400, error_domain=ErrorDomain.INPUT)
@@ -341,11 +341,6 @@ _ERROR_TYPE_STATUS_OVERRIDES: dict[str, int] = {
341
341
  # sandboxed PipeFuncs, not in-process Python) → 403, the same status as the bundle
342
342
  # transport's custom-code gate.
343
343
  "MethodStructuresRefusedError": 403,
344
- # The files submitted in `resources` that the bundle's steps read when the method loads, its HTML templates,
345
- # total more than they may, since they travel inline with the method wherever it runs. Only the parsed bundle
346
- # says which files those are, so the library refuses them rather than this server's decoding: the request
347
- # entity itself is too large → 413, as the decoder's own size refusals are.
348
- "BundleResourcesTooLargeError": 413,
349
344
  }
350
345
 
351
346
 
@@ -23,11 +23,20 @@ MAX_METHOD_REF_LEN = 512 # `method_ref` selector strings; a fixed schema bound,
23
23
  MAX_JSON_NESTING_DEPTH = 128
24
24
  DEFAULT_MAX_CALLBACK_URLS = 5
25
25
  DEFAULT_MAX_CALLBACK_URL_LEN = 2048
26
- DEFAULT_MAX_BUNDLE_FILES = 128 # entries in a materialized method bundle (.mthds + .py + requirements.txt)
26
+ DEFAULT_MAX_BUNDLE_FILES = 128 # members of a `bundle_b64` archive, its shipped packages' files included
27
27
  DEFAULT_MAX_BUNDLE_TOTAL_KIB = 8 * 1024 # 8 MiB decompressed across the whole bundle (zip-bomb guard)
28
28
  DEFAULT_MAX_RESOURCES_PER_REQUEST = 32 # files submitted with a bundle that its steps read (`resources`)
29
29
  DEFAULT_MAX_RESOURCE_FILE_KIB = 5 * 1024 # 5 MiB decoded per submitted file, such as an office template
30
30
  DEFAULT_MAX_RESOURCES_TOTAL_KIB = 10 * 1024 # 10 MiB decoded across a request's submitted files
31
+ # A method tree a request sends (`files`, each tree of `dependencies`, a `bundle_b64` archive) or fetches by `method_ref`,
32
+ # whatever form its files travel in: inline, Base64 or by hash. The request body bounds only the inline entries.
33
+ DEFAULT_MAX_METHOD_TREE_FILES = 1000
34
+ DEFAULT_MAX_METHOD_TREE_TOTAL_KIB = 64 * 1024 # 64 MiB across the files of one tree
35
+ DEFAULT_MAX_METHOD_TREE_FILE_KIB = 32 * 1024 # 32 MiB for one file of a tree, and for one blob uploaded
36
+ # Every tree one request carries together, the method's own and its dependency trees, beside each tree's own limits.
37
+ DEFAULT_MAX_METHOD_DEPENDENCIES = 32 # dependency trees beside the method's own
38
+ DEFAULT_MAX_METHOD_REQUEST_TOTAL_KIB = 128 * 1024 # 128 MiB across the method's tree and its dependency trees
39
+ DEFAULT_BLOB_UPLOAD_URL_TTL_SECONDS = 3600 # how long an upload target `POST /v1/blobs/missing` names stays valid
31
40
  DEFAULT_MAX_METHOD_CACHE_CLONES = 64 # cached method-package clones (one per resolved commit SHA)
32
41
  DEFAULT_MAX_METHOD_CACHE_TOTAL_KIB = 512 * 1024 # 512 MiB across all cached clones
33
42
  DEFAULT_MAX_METHOD_CACHE_AGE_HOURS = 24 # a cached clone unused for this long is evicted
@@ -50,6 +59,15 @@ MAX_RESOURCES_PER_REQUEST = get_positive_int_env(env_var="MAX_RESOURCES_PER_REQU
50
59
  MAX_RESOURCE_FILE_BYTES = get_positive_int_env(env_var="MAX_RESOURCE_FILE_KIB", default=DEFAULT_MAX_RESOURCE_FILE_KIB) * 1024
51
60
  MAX_RESOURCES_TOTAL_BYTES = get_positive_int_env(env_var="MAX_RESOURCES_TOTAL_KIB", default=DEFAULT_MAX_RESOURCES_TOTAL_KIB) * 1024
52
61
 
62
+ MAX_METHOD_TREE_FILES = get_positive_int_env(env_var="MAX_METHOD_TREE_FILES", default=DEFAULT_MAX_METHOD_TREE_FILES)
63
+ MAX_METHOD_TREE_TOTAL_BYTES = get_positive_int_env(env_var="MAX_METHOD_TREE_TOTAL_KIB", default=DEFAULT_MAX_METHOD_TREE_TOTAL_KIB) * 1024
64
+ MAX_METHOD_TREE_FILE_BYTES = get_positive_int_env(env_var="MAX_METHOD_TREE_FILE_KIB", default=DEFAULT_MAX_METHOD_TREE_FILE_KIB) * 1024
65
+
66
+ MAX_METHOD_DEPENDENCIES = get_positive_int_env(env_var="MAX_METHOD_DEPENDENCIES", default=DEFAULT_MAX_METHOD_DEPENDENCIES)
67
+ MAX_METHOD_REQUEST_TOTAL_BYTES = get_positive_int_env(env_var="MAX_METHOD_REQUEST_TOTAL_KIB", default=DEFAULT_MAX_METHOD_REQUEST_TOTAL_KIB) * 1024
68
+
69
+ BLOB_UPLOAD_URL_TTL_SECONDS = get_positive_int_env(env_var="BLOB_UPLOAD_URL_TTL_SECONDS", default=DEFAULT_BLOB_UPLOAD_URL_TTL_SECONDS)
70
+
53
71
  MAX_METHOD_CACHE_CLONES = get_positive_int_env(env_var="MAX_METHOD_CACHE_CLONES", default=DEFAULT_MAX_METHOD_CACHE_CLONES)
54
72
  MAX_METHOD_CACHE_TOTAL_BYTES = get_positive_int_env(env_var="MAX_METHOD_CACHE_TOTAL_KIB", default=DEFAULT_MAX_METHOD_CACHE_TOTAL_KIB) * 1024
55
73
  MAX_METHOD_CACHE_AGE_SECONDS = get_positive_int_env(env_var="MAX_METHOD_CACHE_AGE_HOURS", default=DEFAULT_MAX_METHOD_CACHE_AGE_HOURS) * 3600
@@ -31,6 +31,7 @@ from pipelex_api.exception_handlers import register_exception_handlers
31
31
  from pipelex_api.middleware import RequestIdMiddleware, request_body_size_middleware
32
32
  from pipelex_api.openapi_schema import PipelexFastAPI
33
33
  from pipelex_api.routes import router as api_router
34
+ from pipelex_api.routes.blob_upload import router as blob_upload_router
34
35
  from pipelex_api.routes.health import router as health_router
35
36
  from pipelex_api.routes.version import router as version_router
36
37
  from pipelex_api.security import get_auth_dependency
@@ -187,6 +188,11 @@ fastapi_app.include_router(health_router)
187
188
  # detection before they have credentials).
188
189
  fastapi_app.include_router(version_router, prefix="/v1")
189
190
 
191
+ # `PUT /v1/blobs/{sha256}` is authorized by the signed query of the upload target `POST /v1/blobs/missing` names
192
+ # (`pipelex_api.blob_upload`), so a sender uploads with exactly the headers that target names: it mounts without the
193
+ # auth dependency, and only a caller who passed it on `POST /v1/blobs/missing` holds a target.
194
+ fastapi_app.include_router(blob_upload_router, prefix="/v1")
195
+
190
196
  # Register all other routes WITH authentication, the dependency selected by the AUTH_MODE env var. An
191
197
  # unrecognized AUTH_MODE raises `InvalidAuthModeError` here and the app fails to boot, the same fail-fast
192
198
  # posture as ERROR_DISCLOSURE above: a mistyped mode must never serve these routes unauthenticated.