filebridge 0.1.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.
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.3
2
+ Name: filebridge
3
+ Version: 0.1.0
4
+ Summary: A Python library for interacting with the Filebridge daemon (filebridged).
5
+ Author: Stefan Schönberger
6
+ Author-email: Stefan Schönberger <stefan@sniner.dev>
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: pydantic>=2.12.5
9
+ Requires-Dist: cryptography>=46.0.0
10
+ Requires-Dist: zstandard>=0.25.0
11
+ Requires-Python: >=3.13
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Filebridge Python Client
15
+
16
+ A Python client library for interacting with the [Filebridge daemon](https://github.com/sniner/filebridge) (`filebridged`). Built on `httpx` and `pydantic`, it provides synchronous and asynchronous access to remote files via the filebridge REST API, including chunked streaming and transparent ChaCha20Poly1305 encryption when a token is configured.
17
+
18
+ ## Requirements
19
+
20
+ - Python >= 3.13
21
+ - `httpx` >= 0.28.1
22
+ - `pydantic` >= 2.12.5
23
+ - `cryptography` >= 44.0.0
24
+
25
+ ## Features
26
+
27
+ - **Asynchronous & Synchronous Client**: Full native support for async and sync Python environments.
28
+ - **Streaming Files**: Memory-efficient chunked streaming via `stream_read` and `write_stream`.
29
+ - **Automatic Encryption**: Secure ChaCha20Poly1305 AEAD streaming encryption natively handled whenever a token is used.
30
+
31
+ ## Usage
32
+
33
+ ```python
34
+ from filebridge import FileBridgeClient
35
+
36
+ client = FileBridgeClient("http://localhost:8000")
37
+ loc = client.location("demo", token="my-secret-token")
38
+
39
+ # Read metadata for a file
40
+ info = loc.info("/path/to/file.txt")
41
+ print(f"File size: {info.size} bytes")
42
+
43
+ # Read a file
44
+ data = loc.read("/path/to/file.txt")
45
+
46
+ # Write a file
47
+ loc.write("/path/to/file.txt", b"Hello, World!")
48
+ ```
@@ -0,0 +1,35 @@
1
+ # Filebridge Python Client
2
+
3
+ A Python client library for interacting with the [Filebridge daemon](https://github.com/sniner/filebridge) (`filebridged`). Built on `httpx` and `pydantic`, it provides synchronous and asynchronous access to remote files via the filebridge REST API, including chunked streaming and transparent ChaCha20Poly1305 encryption when a token is configured.
4
+
5
+ ## Requirements
6
+
7
+ - Python >= 3.13
8
+ - `httpx` >= 0.28.1
9
+ - `pydantic` >= 2.12.5
10
+ - `cryptography` >= 44.0.0
11
+
12
+ ## Features
13
+
14
+ - **Asynchronous & Synchronous Client**: Full native support for async and sync Python environments.
15
+ - **Streaming Files**: Memory-efficient chunked streaming via `stream_read` and `write_stream`.
16
+ - **Automatic Encryption**: Secure ChaCha20Poly1305 AEAD streaming encryption natively handled whenever a token is used.
17
+
18
+ ## Usage
19
+
20
+ ```python
21
+ from filebridge import FileBridgeClient
22
+
23
+ client = FileBridgeClient("http://localhost:8000")
24
+ loc = client.location("demo", token="my-secret-token")
25
+
26
+ # Read metadata for a file
27
+ info = loc.info("/path/to/file.txt")
28
+ print(f"File size: {info.size} bytes")
29
+
30
+ # Read a file
31
+ data = loc.read("/path/to/file.txt")
32
+
33
+ # Write a file
34
+ loc.write("/path/to/file.txt", b"Hello, World!")
35
+ ```
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "filebridge"
3
+ version = "0.1.0"
4
+ description = "A Python library for interacting with the Filebridge daemon (filebridged)."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Stefan Schönberger", email = "stefan@sniner.dev" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "httpx>=0.28.1",
12
+ "pydantic>=2.12.5",
13
+ "cryptography>=46.0.0",
14
+ "zstandard>=0.25.0",
15
+ ]
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest (>=9.0.2,<10.0.0)",
20
+ "basedpyright (>=1.38.2,<2.0.0)",
21
+ "ruff>=0.15.4",
22
+ ]
23
+
24
+ [tool.pyright]
25
+ typeCheckingMode = "standard"
26
+ useLibraryCodeForTypes = true
27
+ venvPath = "."
28
+ venv = ".venv"
29
+
30
+ [tool.ruff]
31
+ line-length = 96
32
+ ignore = ["E402"]
33
+
34
+ [tool.ruff.lint]
35
+ per-file-ignores = { "__init__.py" = ["F401"] }
36
+
37
+ [tool.pytest.ini_options]
38
+ pythonpath = ["tests"]
39
+
40
+ [build-system]
41
+ requires = ["uv_build>=0.9.28,<0.10.0"]
42
+ build-backend = "uv_build"
@@ -0,0 +1,35 @@
1
+ from .async_client import (
2
+ AsyncFileBridgeClient,
3
+ AsyncLocation,
4
+ )
5
+ from .exceptions import (
6
+ AuthenticationError,
7
+ FileBridgeError,
8
+ FileBridgePermissionError,
9
+ IsDirectoryError,
10
+ NotFoundError,
11
+ )
12
+ from .io import (
13
+ AsyncFileBridgeReadStream,
14
+ FileBridgeReadStream,
15
+ )
16
+ from .models import (
17
+ Metadata,
18
+ )
19
+ from .sync_client import (
20
+ FileBridgeClient,
21
+ Location,
22
+ )
23
+
24
+ __all__ = [
25
+ "FileBridgeClient",
26
+ "AsyncFileBridgeClient",
27
+ "Location",
28
+ "AsyncLocation",
29
+ "FileBridgeError",
30
+ "AuthenticationError",
31
+ "NotFoundError",
32
+ "IsDirectoryError",
33
+ "FileBridgePermissionError",
34
+ "Metadata",
35
+ ]
@@ -0,0 +1,290 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ from typing import AsyncContextManager, List, Optional, overload
5
+
6
+ import httpx
7
+
8
+ from .core import (
9
+ build_encrypted_write_body,
10
+ decode_read_response,
11
+ get_api_path,
12
+ handle_response_errors,
13
+ parse_json_response,
14
+ prepare_request_kwargs,
15
+ )
16
+ from .exceptions import AuthenticationError, FileBridgeError, IsDirectoryError, NotFoundError
17
+ from .io import AsyncFileBridgeReadStream
18
+ from .models import ListResponse, Metadata
19
+
20
+
21
+ class AsyncLocation:
22
+ def __init__(self, client: AsyncFileBridgeClient, dir_id: str, token: Optional[str] = None):
23
+ self.dir_id = dir_id
24
+ self.token = token
25
+ self._client = client
26
+
27
+ async def _send_request(
28
+ self, method: str, path: str, req_nonce: Optional[str] = None, **kwargs
29
+ ) -> httpx.Response:
30
+ url = f"{self._client.base_url.rstrip('/')}/{get_api_path(self.dir_id, path)}"
31
+ kwargs, generated_nonce = prepare_request_kwargs(
32
+ method=method,
33
+ url=url,
34
+ token=self.token,
35
+ kwargs=kwargs,
36
+ )
37
+ # Use the passed req_nonce if provided (e.g., from write/write_stream where kwargs is prepared early)
38
+ expected_nonce = req_nonce if req_nonce is not None else generated_nonce
39
+
40
+ resp = await self._client.client.request(method, url, **kwargs)
41
+
42
+ if not resp.is_success:
43
+ handle_response_errors(resp.status_code, resp.text)
44
+ raise FileBridgeError(f"HTTP Error {resp.status_code}: {resp.text}")
45
+
46
+ if self.token:
47
+ resp_nonce = resp.headers.get("X-Nonce")
48
+ if resp_nonce != expected_nonce:
49
+ raise AuthenticationError("Nonce mismatch")
50
+ return resp
51
+
52
+ async def read(
53
+ self, path: str, offset: Optional[int] = None, length: Optional[int] = None
54
+ ) -> bytes:
55
+ api_path = get_api_path(self.dir_id, path)
56
+ params = {}
57
+ if offset is not None:
58
+ params["offset"] = offset
59
+ if length is not None:
60
+ params["length"] = length
61
+
62
+ headers = {"Accept": "application/octet-stream"}
63
+ if self.token:
64
+ headers["Accept"] = "application/vnd.filebridge.stream"
65
+
66
+ resp = await self._send_request("GET", api_path, params=params, headers=headers)
67
+
68
+ return decode_read_response(
69
+ self.token,
70
+ resp.headers.get("Content-Type", ""),
71
+ resp.content,
72
+ resp.request.headers.get("X-Signature"),
73
+ path,
74
+ )
75
+
76
+ async def write(self, path: str, data: bytes, offset: Optional[int] = None):
77
+ api_path = get_api_path(self.dir_id, path)
78
+ params = {}
79
+ if offset is not None:
80
+ params["offset"] = offset
81
+
82
+ headers = {"Content-Type": "application/octet-stream"}
83
+ if self.token:
84
+ headers["Content-Type"] = "application/vnd.filebridge.stream"
85
+
86
+ url = f"{self._client.base_url.rstrip('/')}/{api_path}"
87
+ kwargs, _ = prepare_request_kwargs(
88
+ "PUT", url, self.token, {"params": params, "headers": headers}
89
+ )
90
+
91
+ if self.token:
92
+ sig = kwargs.get("headers", {}).get("X-Signature", "")
93
+ kwargs["content"] = build_encrypted_write_body(self.token, sig, data)
94
+ await self._send_request(
95
+ "PUT", path, req_nonce=kwargs.get("headers", {}).get("X-Nonce"), **kwargs
96
+ )
97
+ else:
98
+ kwargs["content"] = data
99
+ await self._send_request("PUT", path, **kwargs)
100
+
101
+ @overload
102
+ def stream_read(
103
+ self,
104
+ path: str,
105
+ offset: Optional[int] = ...,
106
+ length: Optional[int] = ...,
107
+ encoding: None = None,
108
+ ) -> AsyncContextManager[AsyncFileBridgeReadStream]: ...
109
+
110
+ @overload
111
+ def stream_read(
112
+ self,
113
+ path: str,
114
+ offset: Optional[int] = ...,
115
+ length: Optional[int] = ...,
116
+ *,
117
+ encoding: str,
118
+ ) -> AsyncContextManager[AsyncFileBridgeReadStream]: ...
119
+
120
+ @asynccontextmanager
121
+ async def stream_read(
122
+ self,
123
+ path: str,
124
+ offset: Optional[int] = None,
125
+ length: Optional[int] = None,
126
+ encoding: Optional[str] = None,
127
+ ):
128
+ api_path = get_api_path(self.dir_id, path)
129
+ params = {}
130
+ if offset is not None:
131
+ params["offset"] = offset
132
+ if length is not None:
133
+ params["length"] = length
134
+
135
+ headers = {"Accept": "application/octet-stream"}
136
+ if self.token:
137
+ headers["Accept"] = "application/vnd.filebridge.stream"
138
+
139
+ url = f"{self._client.base_url.rstrip('/')}/{api_path}"
140
+ kwargs, req_nonce = prepare_request_kwargs(
141
+ method="GET",
142
+ url=url,
143
+ token=self.token,
144
+ kwargs={"params": params, "headers": headers},
145
+ )
146
+ headers = kwargs.setdefault("headers", {})
147
+
148
+ async with self._client.client.stream("GET", url, **kwargs) as resp:
149
+ if not resp.is_success:
150
+ await resp.aread()
151
+ handle_response_errors(resp.status_code, resp.text)
152
+ raise FileBridgeError(f"HTTP Error {resp.status_code}: {resp.text}")
153
+
154
+ if self.token:
155
+ resp_nonce = resp.headers.get("X-Nonce")
156
+ if resp_nonce != req_nonce:
157
+ raise AuthenticationError("Nonce mismatch")
158
+
159
+ content_type = resp.headers.get("Content-Type", "")
160
+ if "application/json" in content_type:
161
+ body = await resp.aread()
162
+ sig = resp.request.headers.get("X-Signature", "") if self.token else None
163
+ data = parse_json_response(self.token, sig, body)
164
+ if "items" in data:
165
+ raise IsDirectoryError(f"{path} is a directory")
166
+
167
+ raw_stream = AsyncFileBridgeReadStream(resp, self.token)
168
+ if encoding:
169
+ raise NotImplementedError(
170
+ "TextIOWrapper is not supported for async streams directly."
171
+ )
172
+ else:
173
+ async with raw_stream as stream:
174
+ yield stream
175
+
176
+ async def write_stream(self, path: str, stream, offset: Optional[int] = None):
177
+ api_path = get_api_path(self.dir_id, path)
178
+ params = {}
179
+ if offset is not None:
180
+ params["offset"] = offset
181
+
182
+ async def chunk_generator():
183
+ import inspect
184
+
185
+ if hasattr(stream, "read"):
186
+ # If the method returns a coroutine, we await it
187
+ while True:
188
+ result = stream.read(64 * 1024)
189
+ if inspect.isawaitable(result):
190
+ chunk = await result
191
+ else:
192
+ chunk = result
193
+ if not chunk:
194
+ break
195
+ if isinstance(chunk, str):
196
+ chunk = chunk.encode("utf-8")
197
+ yield chunk
198
+ elif hasattr(stream, "__aiter__"):
199
+ async for chunk in stream:
200
+ if isinstance(chunk, str):
201
+ chunk = chunk.encode("utf-8")
202
+ yield chunk
203
+ else:
204
+ for chunk in stream:
205
+ if isinstance(chunk, str):
206
+ chunk = chunk.encode("utf-8")
207
+ yield chunk
208
+
209
+ headers = {"Content-Type": "application/octet-stream"}
210
+ if self.token:
211
+ headers["Content-Type"] = "application/vnd.filebridge.stream"
212
+
213
+ url = f"{self._client.base_url.rstrip('/')}/{api_path}"
214
+ kwargs, _ = prepare_request_kwargs(
215
+ "PUT", url, self.token, {"params": params, "headers": headers}
216
+ )
217
+
218
+ if self.token:
219
+ token = self.token
220
+
221
+ from .stream import (
222
+ StreamAead,
223
+ encode_data,
224
+ encode_stop,
225
+ )
226
+
227
+ async def signed_chunk_generator():
228
+ sig = kwargs.get("headers", {}).get("X-Signature", "")
229
+ aead = StreamAead(token, sig)
230
+ async for chunk in chunk_generator():
231
+ encrypted_chunk = aead.encrypt(chunk)
232
+ yield encode_data(encrypted_chunk)
233
+ yield encode_stop(aead.finalize())
234
+
235
+ kwargs["content"] = signed_chunk_generator()
236
+ await self._send_request(
237
+ "PUT", path, req_nonce=kwargs.get("headers", {}).get("X-Nonce"), **kwargs
238
+ )
239
+ else:
240
+ kwargs["content"] = chunk_generator()
241
+ await self._send_request("PUT", path, **kwargs)
242
+
243
+ async def list(self, path: Optional[str] = None) -> List[Metadata]:
244
+ api_path = get_api_path(self.dir_id, path)
245
+ resp = await self._send_request("GET", api_path)
246
+
247
+ sig = resp.request.headers.get("X-Signature", "") if self.token else None
248
+ data = parse_json_response(self.token, sig, resp.content)
249
+ if "items" not in data:
250
+ meta = Metadata(**data)
251
+ return [meta]
252
+
253
+ list_resp = ListResponse(**data)
254
+ return list_resp.items
255
+
256
+ async def info(self, path: str) -> Metadata:
257
+ api_path = get_api_path(self.dir_id, path)
258
+ resp = await self._send_request("GET", api_path)
259
+ sig = resp.request.headers.get("X-Signature", "") if self.token else None
260
+ data = parse_json_response(self.token, sig, resp.content)
261
+ return Metadata(**data)
262
+
263
+ async def exists(self, path: str) -> bool:
264
+ try:
265
+ await self.info(path)
266
+ return True
267
+ except NotFoundError:
268
+ return False
269
+
270
+ async def delete(self, path: str):
271
+ api_path = get_api_path(self.dir_id, path)
272
+ await self._send_request("DELETE", api_path)
273
+
274
+
275
+ class AsyncFileBridgeClient:
276
+ def __init__(self, base_url: str):
277
+ self.base_url = base_url.rstrip("/") + "/"
278
+ self.client = httpx.AsyncClient(base_url=self.base_url)
279
+
280
+ def location(self, dir_id: str, token: Optional[str] = None) -> AsyncLocation:
281
+ return AsyncLocation(self, dir_id, token)
282
+
283
+ async def close(self):
284
+ await self.client.aclose()
285
+
286
+ async def __aenter__(self):
287
+ return self
288
+
289
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
290
+ await self.close()
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import secrets
7
+ import time
8
+ from typing import Any
9
+ from urllib.parse import urlparse
10
+
11
+ from .exceptions import (
12
+ AuthenticationError,
13
+ FileBridgeError,
14
+ FileBridgePermissionError,
15
+ IsDirectoryError,
16
+ NotFoundError,
17
+ )
18
+
19
+
20
+ def calculate_signature(
21
+ token: str | None, method: str, url: str, timestamp: str, nonce: str
22
+ ) -> str:
23
+ if not token:
24
+ return ""
25
+
26
+ parsed = urlparse(url)
27
+ path = parsed.path
28
+ if parsed.query:
29
+ path += "?" + parsed.query
30
+
31
+ mac = hmac.new(token.encode(), digestmod=hashlib.sha256)
32
+ mac.update(timestamp.encode())
33
+ mac.update(nonce.encode())
34
+ mac.update(method.upper().encode())
35
+ mac.update(path.encode())
36
+
37
+ return mac.hexdigest()
38
+
39
+
40
+ def get_api_path(dir_id: str, path: str | None) -> str:
41
+ """Build the API path, stripping leading slashes from the requested path."""
42
+ if path:
43
+ # Ensure path doesn't start with / to avoid urljoin issues
44
+ clean_path = path.lstrip("/")
45
+ if clean_path:
46
+ return f"api/v1/fs/{dir_id}/{clean_path}"
47
+ return f"api/v1/fs/{dir_id}"
48
+
49
+
50
+ def prepare_request_kwargs(
51
+ method: str, url: str, token: str | None, kwargs: dict[str, Any]
52
+ ) -> tuple[dict[str, Any], str]:
53
+ content = kwargs.get("content", b"")
54
+ if isinstance(content, str):
55
+ kwargs["content"] = content.encode()
56
+ elif kwargs.get("json") is not None:
57
+ kwargs["content"] = json.dumps(kwargs["json"], separators=(",", ":")).encode()
58
+ if "json" in kwargs:
59
+ del kwargs["json"]
60
+
61
+ nonce = ""
62
+ if token:
63
+ headers = kwargs.get("headers", {})
64
+ if "X-Signature" not in headers:
65
+ timestamp = str(int(time.time()))
66
+ nonce = secrets.token_hex(8)
67
+ signature = calculate_signature(token, method, url, timestamp, nonce)
68
+ headers.update(
69
+ {"X-Signature": signature, "X-Timestamp": timestamp, "X-Nonce": nonce}
70
+ )
71
+ kwargs["headers"] = headers
72
+
73
+ return kwargs, nonce
74
+
75
+
76
+ def handle_response_errors(status_code: int, text: str):
77
+ if status_code == 401:
78
+ raise AuthenticationError(f"Authentication failed: {text}")
79
+ if status_code == 403:
80
+ raise FileBridgePermissionError(f"Access Forbidden: {text}")
81
+ if status_code == 404:
82
+ raise NotFoundError(f"Not Found: {text}")
83
+
84
+
85
+ def parse_json_response(token: str | None, signature: str | None, body: bytes) -> dict:
86
+ """Parse a JSON response body, decrypting it first when token+signature are present."""
87
+ parsed = json.loads(body)
88
+ if token and signature:
89
+ from .stream import StreamError, decrypt_json_response
90
+
91
+ message = parsed.get("message")
92
+ if message is None:
93
+ raise FileBridgeError("Missing 'message' field in encrypted response")
94
+ try:
95
+ json_bytes = decrypt_json_response(token, signature, message)
96
+ except StreamError as e:
97
+ raise FileBridgeError(f"JSON response decryption failed: {e}")
98
+ return json.loads(json_bytes)
99
+ return parsed
100
+
101
+
102
+ def decode_read_response(
103
+ token: str | None,
104
+ content_type: str,
105
+ content: bytes,
106
+ sig: str | None,
107
+ path: str,
108
+ ) -> bytes:
109
+ """Evaluate Content-Type and return decoded payload bytes.
110
+
111
+ Raises IsDirectoryError for directory JSON responses, FileBridgeError
112
+ for missing signature in stream mode.
113
+ """
114
+ if "application/json" in content_type:
115
+ data = parse_json_response(token, sig if token else None, content)
116
+ if "items" in data:
117
+ raise IsDirectoryError(f"{path} is a directory")
118
+
119
+ if "application/vnd.filebridge.stream" in content_type:
120
+ if not sig or not token:
121
+ raise FileBridgeError("Missing signature for stream verification")
122
+ return decode_verified_stream_content(token, sig, content)
123
+
124
+ return content
125
+
126
+
127
+ def build_encrypted_write_body(token: str, sig: str, data: bytes) -> bytes:
128
+ """Pack `data` into signed stream frames (ChaCha20Poly1305)."""
129
+ from .stream import StreamAead, encode_data, encode_stop
130
+
131
+ CHUNK_SIZE = 64 * 1024
132
+ aead = StreamAead(token, sig)
133
+ buf = bytearray()
134
+ for i in range(0, len(data), CHUNK_SIZE):
135
+ buf.extend(encode_data(aead.encrypt(data[i : i + CHUNK_SIZE])))
136
+ buf.extend(encode_stop(aead.finalize()))
137
+ return bytes(buf)
138
+
139
+
140
+ def decode_verified_stream_content(token: str, signature: str, content: bytes) -> bytes:
141
+ from .stream import StreamAead, StreamDecoder, StreamError
142
+
143
+ decoder = StreamDecoder()
144
+ aead = StreamAead(token, signature)
145
+ decoder.push(content)
146
+
147
+ result_bytes = bytearray()
148
+ while True:
149
+ frame = decoder.next_frame()
150
+ if not frame:
151
+ break
152
+ tag, sig_str, payload = frame
153
+ if tag == "DATA":
154
+ try:
155
+ result_bytes.extend(aead.decrypt(payload))
156
+ except StreamError:
157
+ raise FileBridgeError("Chunk Authenticated Decryption failed")
158
+ elif tag == "STOP":
159
+ if not sig_str:
160
+ raise FileBridgeError("Stop frame missing signature")
161
+ try:
162
+ aead.verify_stop(sig_str)
163
+ except StreamError:
164
+ raise FileBridgeError("Stop signature mismatch")
165
+ break
166
+ return bytes(result_bytes)
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class FileBridgeError(Exception):
5
+ """Base exception for FileBridge."""
6
+
7
+ pass
8
+
9
+
10
+ class AuthenticationError(FileBridgeError):
11
+ """Raised when authentication fails (401/403)."""
12
+
13
+ pass
14
+
15
+
16
+ class NotFoundError(FileBridgeError):
17
+ """Raised when a resource is not found (404)."""
18
+
19
+ pass
20
+
21
+
22
+ class IsDirectoryError(FileBridgeError):
23
+ """Raised when a file operation is attempted on a directory."""
24
+
25
+ pass
26
+
27
+
28
+ class FileBridgePermissionError(FileBridgeError):
29
+ """Raised when an operation is forbidden due to permissions (403)."""
30
+
31
+ pass
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+
5
+ import httpx
6
+
7
+ from .exceptions import FileBridgeError
8
+ from .stream import StreamError
9
+
10
+
11
+ class FileBridgeReadStream(io.RawIOBase):
12
+ def __init__(self, response: httpx.Response, token: str | None = None):
13
+ self._response = response
14
+ self._token = token
15
+ self._iter = response.iter_bytes()
16
+ self._buffer = bytearray()
17
+ self._eof = False
18
+
19
+ self._is_verified = "application/vnd.filebridge.stream" in response.headers.get(
20
+ "Content-Type", ""
21
+ )
22
+ self._decoder = None
23
+
24
+ if self._is_verified:
25
+ from .stream import StreamDecoder, StreamAead
26
+
27
+ signature = response.request.headers.get("X-Signature")
28
+ if not signature or not self._token:
29
+ raise FileBridgeError("Missing signature for stream verification")
30
+ self._decoder = StreamDecoder()
31
+ self._aead = StreamAead(self._token, signature)
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ return self.__class__.__name__
36
+
37
+ def readable(self) -> bool:
38
+ return True
39
+
40
+ def close(self):
41
+ self._response.close()
42
+ super().close()
43
+
44
+ def _fill_buffer(self) -> bool:
45
+ """Reads from HTTP stream into buffer. Returns True if data was added, False if EOF."""
46
+ if self._eof:
47
+ return False
48
+
49
+ if not self._is_verified:
50
+ try:
51
+ chunk = next(self._iter)
52
+ if chunk:
53
+ self._buffer.extend(chunk)
54
+ return True
55
+ return False
56
+ except StopIteration:
57
+ self._eof = True
58
+ return False
59
+
60
+ # Verified stream logic
61
+ decoder = self._decoder
62
+ aead = self._aead
63
+ assert decoder is not None and aead is not None
64
+
65
+ while not self._eof:
66
+ frame = decoder.next_frame()
67
+ if frame:
68
+ tag, sig_str, payload = frame
69
+ if tag == "DATA":
70
+ try:
71
+ self._buffer.extend(aead.decrypt(payload))
72
+ return True
73
+ except StreamError:
74
+ raise FileBridgeError("Chunk Authenticated Decryption Failed")
75
+ elif tag == "STOP":
76
+ if not sig_str:
77
+ raise FileBridgeError("Stop frame missing signature")
78
+ try:
79
+ aead.verify_stop(sig_str)
80
+ except StreamError:
81
+ raise FileBridgeError("Stop signature mismatch")
82
+ self._eof = True
83
+ return False
84
+ else:
85
+ try:
86
+ decoder.push(next(self._iter))
87
+ except StopIteration:
88
+ raise FileBridgeError("Unexpected EOF before STOP frame")
89
+
90
+ return bool(self._buffer)
91
+
92
+ def readinto(self, b) -> int:
93
+ if not self._buffer and not self._eof:
94
+ self._fill_buffer()
95
+
96
+ if not self._buffer:
97
+ return 0
98
+
99
+ length = min(len(b), len(self._buffer))
100
+ b[:length] = self._buffer[:length]
101
+ del self._buffer[:length]
102
+ return length
103
+
104
+ def read(self, size: int = -1) -> bytes:
105
+ if size == -1 or size is None:
106
+ while not self._eof:
107
+ self._fill_buffer()
108
+ res = bytes(self._buffer)
109
+ self._buffer.clear()
110
+ return res
111
+
112
+ while len(self._buffer) < size and not self._eof:
113
+ self._fill_buffer()
114
+
115
+ length = min(size, len(self._buffer))
116
+ res = bytes(self._buffer[:length])
117
+ del self._buffer[:length]
118
+ return res
119
+
120
+
121
+ class AsyncFileBridgeReadStream:
122
+ def __init__(self, response: httpx.Response, token: str | None = None):
123
+ self._response = response
124
+ self._token = token
125
+ self._iter = response.aiter_bytes()
126
+ self._buffer = bytearray()
127
+ self._eof = False
128
+
129
+ self._is_verified = "application/vnd.filebridge.stream" in response.headers.get(
130
+ "Content-Type", ""
131
+ )
132
+ self._decoder = None
133
+
134
+ if self._is_verified:
135
+ from .stream import StreamDecoder, StreamAead
136
+
137
+ signature = response.request.headers.get("X-Signature")
138
+ if not signature or not self._token:
139
+ raise FileBridgeError("Missing signature for stream verification")
140
+ self._decoder = StreamDecoder()
141
+ self._aead = StreamAead(self._token, signature)
142
+
143
+ async def __aenter__(self):
144
+ return self
145
+
146
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
147
+ await self.close()
148
+
149
+ async def close(self):
150
+ await self._response.aclose()
151
+
152
+ async def _fill_buffer(self) -> bool:
153
+ if self._eof:
154
+ return False
155
+
156
+ if not self._is_verified:
157
+ try:
158
+ chunk = await self._iter.__anext__()
159
+ if chunk:
160
+ self._buffer.extend(chunk)
161
+ return True
162
+ return False
163
+ except StopAsyncIteration:
164
+ self._eof = True
165
+ return False
166
+
167
+ # Verified stream logic
168
+ decoder = self._decoder
169
+ aead = self._aead
170
+ assert decoder is not None and aead is not None
171
+
172
+ while not self._eof:
173
+ frame = decoder.next_frame()
174
+ if frame:
175
+ tag, sig_str, payload = frame
176
+ if tag == "DATA":
177
+ try:
178
+ self._buffer.extend(aead.decrypt(payload))
179
+ return True
180
+ except StreamError:
181
+ raise FileBridgeError("Chunk Authenticated Decryption Failed")
182
+ elif tag == "STOP":
183
+ if not sig_str:
184
+ raise FileBridgeError("Stop frame missing signature")
185
+ try:
186
+ aead.verify_stop(sig_str)
187
+ except StreamError:
188
+ raise FileBridgeError("Stop signature mismatch")
189
+ self._eof = True
190
+ return False
191
+ else:
192
+ try:
193
+ decoder.push(await self._iter.__anext__())
194
+ except StopAsyncIteration:
195
+ raise FileBridgeError("Unexpected EOF before STOP frame")
196
+
197
+ return bool(self._buffer)
198
+
199
+ async def read(self, size: int = -1) -> bytes:
200
+ if size == -1 or size is None:
201
+ while not self._eof:
202
+ await self._fill_buffer()
203
+ res = bytes(self._buffer)
204
+ self._buffer.clear()
205
+ return res
206
+
207
+ while len(self._buffer) < size and not self._eof:
208
+ await self._fill_buffer()
209
+
210
+ length = min(size, len(self._buffer))
211
+ res = bytes(self._buffer[:length])
212
+ del self._buffer[:length]
213
+ return res
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class Metadata(BaseModel):
7
+ name: str
8
+ is_dir: bool
9
+ size: int | None = None
10
+ mdate: str | None = None
11
+ sha256: str | None = None
12
+
13
+
14
+ class ListResponse(BaseModel):
15
+ items: list[Metadata]
16
+ detail: str | None = None
File without changes
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import struct
6
+
7
+ import zstandard
8
+ from cryptography.exceptions import InvalidTag
9
+ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
10
+
11
+
12
+ class StreamError(Exception):
13
+ pass
14
+
15
+
16
+ class StreamAead:
17
+ def __init__(self, token: str, iv_hex: str):
18
+ key_hash = hashlib.sha256(token.encode()).digest()
19
+ self.cipher = ChaCha20Poly1305(key_hash)
20
+
21
+ iv_hasher = hashlib.sha256()
22
+ iv_hasher.update(token.encode())
23
+ iv_hasher.update(iv_hex.encode())
24
+ iv_hash = iv_hasher.digest()
25
+
26
+ self.nonce_base = bytearray(12)
27
+ copy_len = min(len(iv_hash), 12)
28
+ self.nonce_base[:copy_len] = iv_hash[:copy_len]
29
+ self.counter = 0
30
+
31
+ def _current_nonce(self) -> bytes:
32
+ nonce_bytes = bytearray(self.nonce_base)
33
+ counter_bytes = struct.pack(">Q", self.counter)
34
+ for i in range(8):
35
+ nonce_bytes[4 + i] ^= counter_bytes[i]
36
+ return bytes(nonce_bytes)
37
+
38
+ def encrypt(self, data: bytes) -> bytes:
39
+ nonce = self._current_nonce()
40
+ ciphertext = self.cipher.encrypt(nonce, data, None)
41
+ self.counter += 1
42
+ return ciphertext
43
+
44
+ def decrypt(self, data: bytes) -> bytes:
45
+ nonce = self._current_nonce()
46
+ try:
47
+ plaintext = self.cipher.decrypt(nonce, data, None)
48
+ self.counter += 1
49
+ return plaintext
50
+ except InvalidTag as e:
51
+ raise StreamError(f"Decryption failed: {e}")
52
+
53
+ def finalize(self) -> str:
54
+ final_block = self.encrypt(b"")
55
+ return final_block.hex()
56
+
57
+ def verify_stop(self, hex_sig: str):
58
+ try:
59
+ final_block = bytes.fromhex(hex_sig)
60
+ self.decrypt(final_block)
61
+ except (ValueError, StreamError) as e:
62
+ raise StreamError(f"Invalid stop signature: {e}")
63
+
64
+
65
+ def encode_data(payload: bytes) -> bytes:
66
+ frame = bytearray(b"DATA")
67
+ frame.extend(struct.pack(">I", len(payload)))
68
+ frame.extend(payload)
69
+ return bytes(frame)
70
+
71
+
72
+ def encode_stop(signature: str | None = None) -> bytes:
73
+ frame = bytearray(b"STOP")
74
+ if signature:
75
+ sig_bytes = signature.encode()
76
+ frame.extend(struct.pack(">I", len(sig_bytes)))
77
+ frame.extend(sig_bytes)
78
+ else:
79
+ frame.extend(struct.pack(">I", 0))
80
+ return bytes(frame)
81
+
82
+
83
+ def _derive_block_nonce(token: str, iv_hex: str) -> tuple[ChaCha20Poly1305, bytes]:
84
+ """Shared key+nonce derivation for single-block JSON encryption (counter=0)."""
85
+ key_hash = hashlib.sha256(token.encode()).digest()
86
+ cipher = ChaCha20Poly1305(key_hash)
87
+ iv_hasher = hashlib.sha256()
88
+ iv_hasher.update(token.encode())
89
+ iv_hasher.update(iv_hex.encode())
90
+ nonce = iv_hasher.digest()[:12]
91
+ return cipher, nonce
92
+
93
+
94
+ def encrypt_json_response(token: str, iv_hex: str, json_bytes: bytes) -> str:
95
+ """Compress, encrypt and base64-encode JSON bytes for a token-protected response."""
96
+ compressed = zstandard.ZstdCompressor(level=3).compress(json_bytes)
97
+ cipher, nonce = _derive_block_nonce(token, iv_hex)
98
+ ciphertext = cipher.encrypt(nonce, compressed, None)
99
+ return base64.b64encode(ciphertext).decode("ascii")
100
+
101
+
102
+ def decrypt_json_response(token: str, iv_hex: str, encoded: str) -> bytes:
103
+ """Base64-decode, decrypt and decompress an encrypted JSON response."""
104
+ data = base64.b64decode(encoded)
105
+ cipher, nonce = _derive_block_nonce(token, iv_hex)
106
+ try:
107
+ compressed = cipher.decrypt(nonce, data, None)
108
+ except InvalidTag as e:
109
+ raise StreamError(f"JSON response decryption failed: {e}")
110
+ return zstandard.ZstdDecompressor().decompress(compressed)
111
+
112
+
113
+ class StreamDecoder:
114
+ def __init__(self):
115
+ self.buffer = bytearray()
116
+ self.offset = 0
117
+
118
+ def push(self, chunk: bytes):
119
+ self.buffer.extend(chunk)
120
+
121
+ def next_frame(self) -> tuple[str, str | None, bytes] | None:
122
+ rem_len = len(self.buffer) - self.offset
123
+ if rem_len < 8:
124
+ return None
125
+
126
+ tag = bytes(self.buffer[self.offset : self.offset + 4]).decode("ascii")
127
+ length = struct.unpack(">I", self.buffer[self.offset + 4 : self.offset + 8])[0]
128
+
129
+ if rem_len < 8 + length:
130
+ return None
131
+
132
+ payload = bytes(self.buffer[self.offset + 8 : self.offset + 8 + length])
133
+
134
+ # Advance buffer offset
135
+ self.offset += 8 + length
136
+
137
+ # Periodically compact buffer to release memory if we've processed a lot
138
+ if self.offset > 1024 * 1024:
139
+ del self.buffer[:self.offset]
140
+ self.offset = 0
141
+
142
+ sig_str = None
143
+ if tag == "STOP":
144
+ if length > 0:
145
+ sig_str = payload.decode("ascii", errors="replace")
146
+
147
+ return tag, sig_str, payload
@@ -0,0 +1,290 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from contextlib import contextmanager
5
+ from typing import ContextManager, List, Optional, overload
6
+
7
+ import httpx
8
+
9
+ from .core import (
10
+ build_encrypted_write_body,
11
+ decode_read_response,
12
+ get_api_path,
13
+ handle_response_errors,
14
+ parse_json_response,
15
+ prepare_request_kwargs,
16
+ )
17
+ from .exceptions import AuthenticationError, FileBridgeError, IsDirectoryError, NotFoundError
18
+ from .io import FileBridgeReadStream
19
+ from .models import ListResponse, Metadata
20
+
21
+
22
+ class Location:
23
+ def __init__(
24
+ self,
25
+ client: "FileBridgeClient",
26
+ dir_id: str,
27
+ token: Optional[str] = None,
28
+ ):
29
+ self.dir_id = dir_id
30
+ self.token = token
31
+ self.http_client = client.client
32
+ self._client = client
33
+
34
+ def _send_request(
35
+ self, method: str, url: str, kwargs: dict, req_nonce: Optional[str] = None
36
+ ) -> httpx.Response:
37
+ response = self.http_client.request(method, url, **kwargs)
38
+ if not response.is_success:
39
+ handle_response_errors(response.status_code, response.text)
40
+ raise FileBridgeError(f"HTTP Error {response.status_code}: {response.text}")
41
+
42
+ if self.token:
43
+ resp_nonce = response.headers.get("X-Nonce")
44
+ if resp_nonce != req_nonce:
45
+ raise AuthenticationError("Nonce mismatch")
46
+ return response
47
+
48
+ def read(
49
+ self,
50
+ path: str,
51
+ offset: Optional[int] = None,
52
+ length: Optional[int] = None,
53
+ ) -> bytes:
54
+ api_path = get_api_path(self.dir_id, path)
55
+ params = {}
56
+ if offset is not None:
57
+ params["offset"] = offset
58
+ if length is not None:
59
+ params["length"] = length
60
+
61
+ url = f"{self._client.base_url.rstrip('/')}/{api_path}"
62
+ kwargs, req_nonce = prepare_request_kwargs(
63
+ method="GET",
64
+ url=url,
65
+ token=self.token,
66
+ kwargs={"params": params} if params else {},
67
+ )
68
+ headers = kwargs.setdefault("headers", {})
69
+ headers["Accept"] = "application/octet-stream"
70
+ if self.token:
71
+ headers["Accept"] = "application/vnd.filebridge.stream"
72
+
73
+ response = self._send_request("GET", url, kwargs, req_nonce)
74
+
75
+ return decode_read_response(
76
+ self.token,
77
+ response.headers.get("Content-Type", ""),
78
+ response.content,
79
+ response.request.headers.get("X-Signature"),
80
+ path,
81
+ )
82
+
83
+ def write(self, path: str, data: bytes, offset: Optional[int] = None):
84
+ api_path = get_api_path(self.dir_id, path)
85
+ params = {}
86
+ if offset is not None:
87
+ params["offset"] = offset
88
+
89
+ headers = {"Content-Type": "application/octet-stream"}
90
+ if self.token:
91
+ headers["Content-Type"] = "application/vnd.filebridge.stream"
92
+
93
+ url = f"{self._client.base_url.rstrip('/')}/{api_path}"
94
+ kwargs, req_nonce = prepare_request_kwargs(
95
+ "PUT", url, self.token, {"params": params, "headers": headers}
96
+ )
97
+
98
+ if self.token:
99
+ sig = kwargs.get("headers", {}).get("X-Signature", "")
100
+ kwargs["content"] = build_encrypted_write_body(self.token, sig, data)
101
+ self._send_request("PUT", url, kwargs, req_nonce)
102
+ else:
103
+ kwargs["content"] = data
104
+ self._send_request("PUT", url, kwargs, req_nonce)
105
+
106
+ @overload
107
+ def stream_read(
108
+ self,
109
+ path: str,
110
+ offset: Optional[int] = ...,
111
+ length: Optional[int] = ...,
112
+ encoding: None = None,
113
+ ) -> ContextManager[FileBridgeReadStream]: ...
114
+
115
+ @overload
116
+ def stream_read(
117
+ self,
118
+ path: str,
119
+ offset: Optional[int] = ...,
120
+ length: Optional[int] = ...,
121
+ *,
122
+ encoding: str,
123
+ ) -> ContextManager[io.TextIOWrapper]: ...
124
+
125
+ @contextmanager
126
+ def stream_read(
127
+ self,
128
+ path: str,
129
+ offset: Optional[int] = None,
130
+ length: Optional[int] = None,
131
+ encoding: Optional[str] = None,
132
+ ):
133
+ api_path = get_api_path(self.dir_id, path)
134
+ params = {}
135
+ if offset is not None:
136
+ params["offset"] = offset
137
+ if length is not None:
138
+ params["length"] = length
139
+
140
+ headers = {"Accept": "application/octet-stream"}
141
+ if self.token:
142
+ headers["Accept"] = "application/vnd.filebridge.stream"
143
+
144
+ url = f"{self._client.base_url.rstrip('/')}/{api_path}"
145
+ kwargs, req_nonce = prepare_request_kwargs(
146
+ method="GET",
147
+ url=url,
148
+ token=self.token,
149
+ kwargs={"params": params, "headers": headers},
150
+ )
151
+ headers = kwargs.setdefault("headers", {})
152
+
153
+ with self.http_client.stream("GET", url, **kwargs) as response:
154
+ if not response.is_success:
155
+ response.read() # Ensure content is read before raising for error handling
156
+ handle_response_errors(response.status_code, response.text)
157
+ raise FileBridgeError(f"HTTP Error {response.status_code}: {response.text}")
158
+
159
+ if self.token:
160
+ resp_nonce = response.headers.get("X-Nonce")
161
+ if resp_nonce != req_nonce:
162
+ raise AuthenticationError("Nonce mismatch")
163
+
164
+ content_type = response.headers.get("Content-Type", "")
165
+ if "application/json" in content_type:
166
+ body = response.read()
167
+ sig = response.request.headers.get("X-Signature", "") if self.token else None
168
+ data = parse_json_response(self.token, sig, body)
169
+ if "items" in data:
170
+ raise IsDirectoryError(f"{path} is a directory")
171
+
172
+ raw_stream = FileBridgeReadStream(response, self.token)
173
+ if encoding:
174
+ wrapper = io.TextIOWrapper(raw_stream, encoding=encoding)
175
+ try:
176
+ yield wrapper
177
+ finally:
178
+ wrapper.close()
179
+ else:
180
+ try:
181
+ yield raw_stream
182
+ finally:
183
+ raw_stream.close()
184
+
185
+ def write_stream(self, path: str, stream, offset: Optional[int] = None):
186
+ api_path = get_api_path(self.dir_id, path)
187
+ params = {}
188
+ if offset is not None:
189
+ params["offset"] = offset
190
+
191
+ headers = {"Content-Type": "application/octet-stream"}
192
+ if self.token:
193
+ headers["Content-Type"] = "application/vnd.filebridge.stream"
194
+
195
+ url = f"{self._client.base_url.rstrip('/')}/{api_path}"
196
+ kwargs, req_nonce = prepare_request_kwargs(
197
+ "PUT", url, self.token, {"params": params, "headers": headers}
198
+ )
199
+
200
+ def chunk_generator():
201
+ if hasattr(stream, "read"):
202
+ while True:
203
+ chunk = stream.read(64 * 1024)
204
+ if not chunk:
205
+ break
206
+ if isinstance(chunk, str):
207
+ chunk = chunk.encode("utf-8")
208
+ yield chunk
209
+ else:
210
+ for chunk in stream:
211
+ if isinstance(chunk, str):
212
+ chunk = chunk.encode("utf-8")
213
+ yield chunk
214
+
215
+ if self.token:
216
+ from .stream import (
217
+ StreamAead,
218
+ encode_data,
219
+ encode_stop,
220
+ )
221
+
222
+ token = self.token
223
+ assert token is not None
224
+
225
+ def signed_chunk_generator():
226
+ sig = kwargs.get("headers", {}).get("X-Signature", "")
227
+ aead = StreamAead(token, sig)
228
+ for chunk in chunk_generator():
229
+ encrypted_chunk = aead.encrypt(chunk)
230
+ yield encode_data(encrypted_chunk)
231
+
232
+ yield encode_stop(aead.finalize())
233
+
234
+ kwargs["content"] = signed_chunk_generator()
235
+ self._send_request("PUT", url, kwargs, req_nonce)
236
+ else:
237
+ kwargs["content"] = chunk_generator()
238
+ self._send_request("PUT", url, kwargs, req_nonce)
239
+
240
+ def list(self, path: Optional[str] = None) -> List[Metadata]:
241
+ url = f"{self._client.base_url.rstrip('/')}/{get_api_path(self.dir_id, path)}"
242
+ kwargs, req_nonce = prepare_request_kwargs("GET", url, self.token, {})
243
+ response = self._send_request("GET", url, kwargs, req_nonce)
244
+
245
+ sig = response.request.headers.get("X-Signature", "") if self.token else None
246
+ data = parse_json_response(self.token, sig, response.content)
247
+ if "items" not in data:
248
+ meta = Metadata(**data)
249
+ return [meta]
250
+
251
+ list_resp = ListResponse(**data)
252
+ return list_resp.items
253
+
254
+ def info(self, path: str) -> Metadata:
255
+ url = f"{self._client.base_url.rstrip('/')}/{get_api_path(self.dir_id, path)}"
256
+ kwargs, req_nonce = prepare_request_kwargs("GET", url, self.token, {})
257
+ response = self._send_request("GET", url, kwargs, req_nonce)
258
+ sig = response.request.headers.get("X-Signature", "") if self.token else None
259
+ data = parse_json_response(self.token, sig, response.content)
260
+ return Metadata(**data)
261
+
262
+ def exists(self, path: str) -> bool:
263
+ try:
264
+ self.info(path)
265
+ return True
266
+ except NotFoundError:
267
+ return False
268
+
269
+ def delete(self, path: str):
270
+ url = f"{self._client.base_url.rstrip('/')}/{get_api_path(self.dir_id, path)}"
271
+ kwargs, req_nonce = prepare_request_kwargs("DELETE", url, self.token, {})
272
+ self._send_request("DELETE", url, kwargs, req_nonce)
273
+
274
+
275
+ class FileBridgeClient:
276
+ def __init__(self, base_url: str):
277
+ self.base_url = base_url.rstrip("/") + "/"
278
+ self.client = httpx.Client(base_url=self.base_url)
279
+
280
+ def location(self, dir_id: str, token: Optional[str] = None) -> Location:
281
+ return Location(self, dir_id, token)
282
+
283
+ def close(self):
284
+ self.client.close()
285
+
286
+ def __enter__(self):
287
+ return self
288
+
289
+ def __exit__(self, exc_type, exc_val, exc_tb):
290
+ self.close()