mfup-core 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mfup_core/__init__.py +66 -0
- mfup_core/hooks.py +203 -0
- mfup_core/protocol.py +347 -0
- mfup_core/publish.py +219 -0
- mfup_core/py.typed +0 -0
- mfup_core/redis_index.py +97 -0
- mfup_core/session_manager.py +974 -0
- mfup_core/storage.py +435 -0
- mfup_core-0.2.0.dist-info/METADATA +54 -0
- mfup_core-0.2.0.dist-info/RECORD +12 -0
- mfup_core-0.2.0.dist-info/WHEEL +4 -0
- mfup_core-0.2.0.dist-info/licenses/LICENSE +21 -0
mfup_core/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""mfup-core — the MFUP/2 upload engine, framework-free.
|
|
2
|
+
|
|
3
|
+
Protocol codec, per-session SQLite staging, the session state machine,
|
|
4
|
+
Redis expiry index, publish/mapping, and the consumer hook contracts.
|
|
5
|
+
HTTP/WebSocket wiring lives in the companion package ``mfup-fastapi``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .protocol import (
|
|
9
|
+
CRC32C_IMPL,
|
|
10
|
+
PROTOCOL_VERSION,
|
|
11
|
+
FrameReader,
|
|
12
|
+
SessionState,
|
|
13
|
+
)
|
|
14
|
+
from .hooks import (
|
|
15
|
+
AuthRequest,
|
|
16
|
+
AuthResult,
|
|
17
|
+
AuthorizeHook,
|
|
18
|
+
CommitEvent,
|
|
19
|
+
FileMapRequest,
|
|
20
|
+
MapFileHook,
|
|
21
|
+
OnCommittedHook,
|
|
22
|
+
load_authorize_hook,
|
|
23
|
+
load_hook,
|
|
24
|
+
load_map_file_hook,
|
|
25
|
+
load_on_committed_hook,
|
|
26
|
+
resolve_hook,
|
|
27
|
+
)
|
|
28
|
+
from .session_manager import LiveSession, SessionRegistry
|
|
29
|
+
from .redis_index import SessionIndex
|
|
30
|
+
from .publish import (
|
|
31
|
+
ConflictError,
|
|
32
|
+
MappingError,
|
|
33
|
+
list_payload_files,
|
|
34
|
+
publish_session,
|
|
35
|
+
publish_session_mapped,
|
|
36
|
+
)
|
|
37
|
+
from .storage import staging_dir, validate_node_name
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"AuthRequest",
|
|
41
|
+
"AuthResult",
|
|
42
|
+
"AuthorizeHook",
|
|
43
|
+
"CommitEvent",
|
|
44
|
+
"ConflictError",
|
|
45
|
+
"CRC32C_IMPL",
|
|
46
|
+
"FileMapRequest",
|
|
47
|
+
"FrameReader",
|
|
48
|
+
"list_payload_files",
|
|
49
|
+
"LiveSession",
|
|
50
|
+
"load_authorize_hook",
|
|
51
|
+
"load_hook",
|
|
52
|
+
"load_map_file_hook",
|
|
53
|
+
"load_on_committed_hook",
|
|
54
|
+
"MapFileHook",
|
|
55
|
+
"MappingError",
|
|
56
|
+
"OnCommittedHook",
|
|
57
|
+
"PROTOCOL_VERSION",
|
|
58
|
+
"publish_session",
|
|
59
|
+
"publish_session_mapped",
|
|
60
|
+
"resolve_hook",
|
|
61
|
+
"SessionIndex",
|
|
62
|
+
"SessionRegistry",
|
|
63
|
+
"SessionState",
|
|
64
|
+
"staging_dir",
|
|
65
|
+
"validate_node_name",
|
|
66
|
+
]
|
mfup_core/hooks.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""MFUP/2 extension hooks — config-driven, no library packaging required.
|
|
2
|
+
|
|
3
|
+
A consumer plugs their code in via environment variables holding dotted
|
|
4
|
+
paths, e.g.:
|
|
5
|
+
|
|
6
|
+
MFUP_AUTHORIZE=myapp.uploads:authorize
|
|
7
|
+
|
|
8
|
+
The referenced module just has to be importable (on PYTHONPATH / mounted
|
|
9
|
+
into the container). The full contract lives in docs/EXTENDING.md.
|
|
10
|
+
|
|
11
|
+
Authorize contract
|
|
12
|
+
------------------
|
|
13
|
+
|
|
14
|
+
async def authorize(req: AuthRequest) -> AuthResult | None:
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
Called once per HELLO, before the session is created. Return:
|
|
18
|
+
- ``AuthResult(...)`` — allow, optionally constraining the session;
|
|
19
|
+
- ``None`` — deny → the client gets SESSION_ABORT(auth_failed).
|
|
20
|
+
|
|
21
|
+
Raising is treated as a deny (logged server-side, generic reason sent).
|
|
22
|
+
|
|
23
|
+
If ``MFUP_AUTHORIZE`` is unset the server runs **allow-all** and logs a
|
|
24
|
+
warning at startup — acceptable for development, not for production.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import importlib
|
|
30
|
+
import logging
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Any, Awaitable, Callable, Mapping, Optional
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("mfup.hooks")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class AuthRequest:
|
|
39
|
+
"""Everything the server knows about an upload attempt at HELLO time."""
|
|
40
|
+
|
|
41
|
+
session_id: str
|
|
42
|
+
target_dir: str
|
|
43
|
+
#: HTTP headers of the WebSocket handshake (cookies, Authorization, …).
|
|
44
|
+
headers: Mapping[str, str]
|
|
45
|
+
#: Client address as reported by the ASGI server ("ip:port" or "").
|
|
46
|
+
client: str
|
|
47
|
+
#: Query parameters of the WebSocket URL.
|
|
48
|
+
query: Mapping[str, str]
|
|
49
|
+
#: Arbitrary JSON the CONSUMER'S FRONTEND attached to the session
|
|
50
|
+
#: (MfupSessionConfig.meta → HELLO.meta). Untrusted client input — the
|
|
51
|
+
#: hook validates it. Typical use: upload scope/purpose ("avatars",
|
|
52
|
+
#: {"album_id": 123}) that authorization and per-file mapping key on.
|
|
53
|
+
meta: Any = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class AuthResult:
|
|
58
|
+
"""Permission plus per-session constraints. All limits optional."""
|
|
59
|
+
|
|
60
|
+
#: Cap on total accepted payload bytes; exceeding aborts the session
|
|
61
|
+
#: with SESSION_ABORT(quota_exceeded). None = unlimited.
|
|
62
|
+
max_total_bytes: Optional[int] = None
|
|
63
|
+
#: Cap on the number of file nodes; exceeding aborts likewise.
|
|
64
|
+
max_files: Optional[int] = None
|
|
65
|
+
#: Per-session BASE directory (absolute path) — e.g. the user's home.
|
|
66
|
+
#: Overrides MFUP_BASE_DIR for this session: the staging dir is created
|
|
67
|
+
#: inside it (publish stays a same-filesystem rename even when homes live
|
|
68
|
+
#: on their own mount), relative target_dir resolves against it, and the
|
|
69
|
+
#: containment check confines the session to it. Created if missing.
|
|
70
|
+
#: None = the global MFUP_BASE_DIR.
|
|
71
|
+
base_dir: Optional[str] = None
|
|
72
|
+
#: Optional override of the client-requested target_dir (e.g. force
|
|
73
|
+
#: uploads into a fixed subdirectory, or a MAPPING of the client's
|
|
74
|
+
#: request — the hook receives req.target_dir and may prefix/rewrite it:
|
|
75
|
+
#: target_dir=f"incoming/{req.target_dir}"
|
|
76
|
+
#: Escapes are impossible regardless: the resolved target must stay
|
|
77
|
+
#: within the session's base_dir or HELLO is refused (bad_target_dir).
|
|
78
|
+
target_dir: Optional[str] = None
|
|
79
|
+
#: Free-form bag the consumer may use to correlate sessions with users;
|
|
80
|
+
#: stored in memory on the LiveSession, never persisted or sent to the
|
|
81
|
+
#: client.
|
|
82
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
AuthorizeHook = Callable[[AuthRequest], Awaitable[Optional[AuthResult]]]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class FileMapRequest:
|
|
90
|
+
"""One file about to be published — input to the map_file hook."""
|
|
91
|
+
|
|
92
|
+
session_id: str
|
|
93
|
+
#: Path of the file inside the uploaded tree, "/"-separated, as the
|
|
94
|
+
#: client sent it (e.g. "photos/2024/img_001.jpg").
|
|
95
|
+
path: str
|
|
96
|
+
#: Basename convenience (last segment of `path`).
|
|
97
|
+
name: str
|
|
98
|
+
#: Actual size on disk, bytes.
|
|
99
|
+
size: int
|
|
100
|
+
#: The session's target_dir (already authorized/mapped at HELLO).
|
|
101
|
+
target_dir: str
|
|
102
|
+
#: Client-attached session meta (see AuthRequest.meta).
|
|
103
|
+
meta: Any
|
|
104
|
+
#: AuthResult.context from the authorize hook.
|
|
105
|
+
context: Mapping[str, Any]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
#: async (FileMapRequest) -> str | None
|
|
109
|
+
#: str — new path RELATIVE to target_dir (e.g. "media/img_001.jpg");
|
|
110
|
+
#: None — keep the client's layout for this file.
|
|
111
|
+
#: Two files mapping to the same destination is a consumer bug → publish
|
|
112
|
+
#: fails with mapping_conflict. Escaping segments ("..", absolute, "\\")
|
|
113
|
+
#: fail publish likewise. The hook runs once per file at PUBLISH time, so
|
|
114
|
+
#: it does not need to be deterministic across retries of the transfer.
|
|
115
|
+
MapFileHook = Callable[[FileMapRequest], Awaitable[Optional[str]]]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class CommitEvent:
|
|
120
|
+
"""A session just committed — input to the on_committed hook."""
|
|
121
|
+
|
|
122
|
+
session_id: str
|
|
123
|
+
#: The session's (authorized/mapped) target_dir.
|
|
124
|
+
target_dir: str
|
|
125
|
+
#: The session's base directory (per-user home or the global base).
|
|
126
|
+
base_dir: str
|
|
127
|
+
#: Absolute staging directory holding the committed payload.
|
|
128
|
+
staging_dir: str
|
|
129
|
+
#: Committed file count / total payload bytes (as sent in COMMIT_OK).
|
|
130
|
+
files: int
|
|
131
|
+
bytes: int
|
|
132
|
+
#: Client-attached session meta (see AuthRequest.meta). Untrusted.
|
|
133
|
+
meta: Any
|
|
134
|
+
#: AuthResult.context from the authorize hook.
|
|
135
|
+
context: Mapping[str, Any]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
#: async (CommitEvent) -> str | None
|
|
139
|
+
#: "publish" — the server publishes immediately (server-side decision:
|
|
140
|
+
#: scan passed, billing ok, …). The browser's own publish call,
|
|
141
|
+
#: if any, will find the session gone (404) — harmless.
|
|
142
|
+
#: None — do nothing; publish stays client-driven (or the consumer
|
|
143
|
+
#: backend calls MfupEngine.publish() later).
|
|
144
|
+
#: Raising is logged and treated as None — a broken consumer hook must not
|
|
145
|
+
#: strand committed sessions.
|
|
146
|
+
OnCommittedHook = Callable[[CommitEvent], Awaitable[Optional[str]]]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_hook(dotted: str) -> Callable[..., Any]:
|
|
150
|
+
"""Import ``pkg.module:attr`` and return the attribute.
|
|
151
|
+
|
|
152
|
+
Raises ImportError/AttributeError loudly — a misconfigured hook must
|
|
153
|
+
fail at startup, not silently run allow-all.
|
|
154
|
+
"""
|
|
155
|
+
module_path, sep, attr = dotted.partition(":")
|
|
156
|
+
if not sep or not module_path or not attr:
|
|
157
|
+
raise ImportError(
|
|
158
|
+
f"invalid hook path {dotted!r}: expected 'package.module:callable'"
|
|
159
|
+
)
|
|
160
|
+
module = importlib.import_module(module_path)
|
|
161
|
+
return getattr(module, attr)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def load_authorize_hook(dotted: str | None) -> AuthorizeHook | None:
|
|
165
|
+
"""Resolve MFUP_AUTHORIZE. None (unset) → allow-all with a warning."""
|
|
166
|
+
if not dotted:
|
|
167
|
+
logger.warning(
|
|
168
|
+
"MFUP_AUTHORIZE is not set — running WITHOUT authorization "
|
|
169
|
+
"(allow-all). Do not do this in production."
|
|
170
|
+
)
|
|
171
|
+
return None
|
|
172
|
+
hook = load_hook(dotted)
|
|
173
|
+
logger.info("Authorize hook loaded: %s", dotted)
|
|
174
|
+
return hook # type: ignore[return-value]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_map_file_hook(dotted: str | None) -> MapFileHook | None:
|
|
178
|
+
"""Resolve MFUP_MAP_FILE. None (unset) → identity layout."""
|
|
179
|
+
if not dotted:
|
|
180
|
+
return None
|
|
181
|
+
hook = load_hook(dotted)
|
|
182
|
+
logger.info("map_file hook loaded: %s", dotted)
|
|
183
|
+
return hook # type: ignore[return-value]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_on_committed_hook(dotted: str | None) -> OnCommittedHook | None:
|
|
187
|
+
"""Resolve MFUP_ON_COMMITTED. None (unset) → client-driven publish."""
|
|
188
|
+
if not dotted:
|
|
189
|
+
return None
|
|
190
|
+
hook = load_hook(dotted)
|
|
191
|
+
logger.info("on_committed hook loaded: %s", dotted)
|
|
192
|
+
return hook # type: ignore[return-value]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def resolve_hook(ref: Callable[..., Any] | str | None) -> Callable[..., Any] | None:
|
|
196
|
+
"""Accept a hook given either as the callable itself (library embedding:
|
|
197
|
+
``MfupConfig(authorize=my_func)``) or as a dotted path (env-driven:
|
|
198
|
+
``"pkg.module:callable"``). None passes through."""
|
|
199
|
+
if ref is None:
|
|
200
|
+
return None
|
|
201
|
+
if callable(ref):
|
|
202
|
+
return ref
|
|
203
|
+
return load_hook(ref)
|
mfup_core/protocol.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""MFUP/2 protocol types and binary frame decoder (server-side)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import struct
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PROTOCOL_VERSION = "MFUP/2"
|
|
12
|
+
ROOT_NODE_ID = 0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Frame tags
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
class FrameTag(enum.IntEnum):
|
|
19
|
+
NODE = 0x01
|
|
20
|
+
SUMMARY = 0x02
|
|
21
|
+
FILE_OPEN = 0x03
|
|
22
|
+
FILE_CHUNK = 0x04
|
|
23
|
+
FILE_CLOSE = 0x05
|
|
24
|
+
DIR_CLOSE = 0x06
|
|
25
|
+
SESSION_END = 0x07
|
|
26
|
+
CLIENT_ABORT = 0x08
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NodeKind(enum.IntEnum):
|
|
30
|
+
DIR = 0x00
|
|
31
|
+
FILE = 0x01
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ChecksumKind(enum.IntEnum):
|
|
35
|
+
CRC32C = 0x01
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Decoded frame dataclasses
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class NodeFrame:
|
|
43
|
+
tag: FrameTag = FrameTag.NODE
|
|
44
|
+
node_id: int = 0
|
|
45
|
+
parent_id: int = 0
|
|
46
|
+
kind: NodeKind = NodeKind.FILE
|
|
47
|
+
name: str = ""
|
|
48
|
+
size_hint: Optional[int] = None
|
|
49
|
+
mtime_ms: Optional[int] = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class SummaryFrame:
|
|
54
|
+
tag: FrameTag = FrameTag.SUMMARY
|
|
55
|
+
node_id: int = 0
|
|
56
|
+
scan_done_units: int = 0
|
|
57
|
+
scan_est_units: int = 0
|
|
58
|
+
body_done_bytes: int = 0
|
|
59
|
+
body_est_bytes: int = 0
|
|
60
|
+
sealed: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class FileOpenFrame:
|
|
65
|
+
tag: FrameTag = FrameTag.FILE_OPEN
|
|
66
|
+
node_id: int = 0
|
|
67
|
+
size: int = 0
|
|
68
|
+
mtime_ms: Optional[int] = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True)
|
|
72
|
+
class FileChunkFrame:
|
|
73
|
+
tag: FrameTag = FrameTag.FILE_CHUNK
|
|
74
|
+
node_id: int = 0
|
|
75
|
+
offset: int = 0
|
|
76
|
+
length: int = 0
|
|
77
|
+
checksum_kind: ChecksumKind = ChecksumKind.CRC32C
|
|
78
|
+
checksum: int = 0
|
|
79
|
+
payload: bytes = b""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(slots=True)
|
|
83
|
+
class FileCloseFrame:
|
|
84
|
+
tag: FrameTag = FrameTag.FILE_CLOSE
|
|
85
|
+
node_id: int = 0
|
|
86
|
+
size_sent: int = 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(slots=True)
|
|
90
|
+
class DirCloseFrame:
|
|
91
|
+
tag: FrameTag = FrameTag.DIR_CLOSE
|
|
92
|
+
node_id: int = 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(slots=True)
|
|
96
|
+
class SessionEndFrame:
|
|
97
|
+
tag: FrameTag = FrameTag.SESSION_END
|
|
98
|
+
scan_done_units: int = 0
|
|
99
|
+
scan_est_units: int = 0
|
|
100
|
+
body_done_bytes: int = 0
|
|
101
|
+
body_est_bytes: int = 0
|
|
102
|
+
sealed: bool = True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(slots=True)
|
|
106
|
+
class ClientAbortFrame:
|
|
107
|
+
tag: FrameTag = FrameTag.CLIENT_ABORT
|
|
108
|
+
code: str = ""
|
|
109
|
+
reason: str = ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
Frame = (
|
|
113
|
+
NodeFrame
|
|
114
|
+
| SummaryFrame
|
|
115
|
+
| FileOpenFrame
|
|
116
|
+
| FileChunkFrame
|
|
117
|
+
| FileCloseFrame
|
|
118
|
+
| DirCloseFrame
|
|
119
|
+
| SessionEndFrame
|
|
120
|
+
| ClientAbortFrame
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Server → Client control message: {"t": "PROBE_ACK"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Session states
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
class SessionState(str, enum.Enum):
|
|
131
|
+
ACTIVE = "active"
|
|
132
|
+
PAUSED_BY_SERVER = "paused_by_server"
|
|
133
|
+
WAITING_RESUME = "waiting_resume"
|
|
134
|
+
COMMITTING = "committing"
|
|
135
|
+
COMMITTED = "committed"
|
|
136
|
+
ABORTED = "aborted"
|
|
137
|
+
EXPIRED = "expired"
|
|
138
|
+
FAILED = "failed"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Node statuses (in DB)
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
class NodeStatus(str, enum.Enum):
|
|
145
|
+
OPEN = "open"
|
|
146
|
+
CLOSED = "closed"
|
|
147
|
+
REJECTED = "rejected"
|
|
148
|
+
PRUNED = "pruned"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Binary frame decoder
|
|
153
|
+
#
|
|
154
|
+
# Wire format: [4-byte big-endian length][1-byte tag][payload]
|
|
155
|
+
# The length covers tag + payload.
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _read_u8(data: memoryview, off: int) -> tuple[int, int]:
|
|
159
|
+
return data[off], off + 1
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _read_u16(data: memoryview, off: int) -> tuple[int, int]:
|
|
163
|
+
return struct.unpack_from("!H", data, off)[0], off + 2
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _read_u32(data: memoryview, off: int) -> tuple[int, int]:
|
|
167
|
+
return struct.unpack_from("!I", data, off)[0], off + 4
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _read_u64(data: memoryview, off: int) -> tuple[int, int]:
|
|
171
|
+
return struct.unpack_from("!Q", data, off)[0], off + 8
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _read_string(data: memoryview, off: int) -> tuple[str, int]:
|
|
175
|
+
slen, off = _read_u16(data, off)
|
|
176
|
+
s = bytes(data[off : off + slen]).decode("utf-8")
|
|
177
|
+
return s, off + slen
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def decode_frame_payload(tag: int, payload: memoryview) -> Frame:
|
|
181
|
+
"""Decode the payload bytes (after the tag byte) into a Frame dataclass."""
|
|
182
|
+
off = 0
|
|
183
|
+
|
|
184
|
+
if tag == FrameTag.NODE:
|
|
185
|
+
node_id, off = _read_u32(payload, off)
|
|
186
|
+
parent_id, off = _read_u32(payload, off)
|
|
187
|
+
kind_val, off = _read_u8(payload, off)
|
|
188
|
+
name, off = _read_string(payload, off)
|
|
189
|
+
has_size, off = _read_u8(payload, off)
|
|
190
|
+
size_hint = None
|
|
191
|
+
if has_size:
|
|
192
|
+
size_hint, off = _read_u64(payload, off)
|
|
193
|
+
has_mtime, off = _read_u8(payload, off)
|
|
194
|
+
mtime_ms = None
|
|
195
|
+
if has_mtime:
|
|
196
|
+
mtime_ms, off = _read_u64(payload, off)
|
|
197
|
+
return NodeFrame(
|
|
198
|
+
node_id=node_id,
|
|
199
|
+
parent_id=parent_id,
|
|
200
|
+
kind=NodeKind(kind_val),
|
|
201
|
+
name=name,
|
|
202
|
+
size_hint=size_hint,
|
|
203
|
+
mtime_ms=mtime_ms,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
elif tag == FrameTag.SUMMARY:
|
|
207
|
+
node_id, off = _read_u32(payload, off)
|
|
208
|
+
scan_done, off = _read_u64(payload, off)
|
|
209
|
+
scan_est, off = _read_u64(payload, off)
|
|
210
|
+
body_done, off = _read_u64(payload, off)
|
|
211
|
+
body_est, off = _read_u64(payload, off)
|
|
212
|
+
sealed_val, off = _read_u8(payload, off)
|
|
213
|
+
return SummaryFrame(
|
|
214
|
+
node_id=node_id,
|
|
215
|
+
scan_done_units=scan_done,
|
|
216
|
+
scan_est_units=scan_est,
|
|
217
|
+
body_done_bytes=body_done,
|
|
218
|
+
body_est_bytes=body_est,
|
|
219
|
+
sealed=bool(sealed_val),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
elif tag == FrameTag.FILE_OPEN:
|
|
223
|
+
node_id, off = _read_u32(payload, off)
|
|
224
|
+
size, off = _read_u64(payload, off)
|
|
225
|
+
has_mtime, off = _read_u8(payload, off)
|
|
226
|
+
mtime_ms = None
|
|
227
|
+
if has_mtime:
|
|
228
|
+
mtime_ms, off = _read_u64(payload, off)
|
|
229
|
+
return FileOpenFrame(node_id=node_id, size=size, mtime_ms=mtime_ms)
|
|
230
|
+
|
|
231
|
+
elif tag == FrameTag.FILE_CHUNK:
|
|
232
|
+
node_id, off = _read_u32(payload, off)
|
|
233
|
+
offset_val, off = _read_u64(payload, off)
|
|
234
|
+
length, off = _read_u32(payload, off)
|
|
235
|
+
ck_kind, off = _read_u8(payload, off)
|
|
236
|
+
checksum, off = _read_u32(payload, off)
|
|
237
|
+
data = bytes(payload[off : off + length])
|
|
238
|
+
return FileChunkFrame(
|
|
239
|
+
node_id=node_id,
|
|
240
|
+
offset=offset_val,
|
|
241
|
+
length=length,
|
|
242
|
+
checksum_kind=ChecksumKind(ck_kind),
|
|
243
|
+
checksum=checksum,
|
|
244
|
+
payload=data,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
elif tag == FrameTag.FILE_CLOSE:
|
|
248
|
+
node_id, off = _read_u32(payload, off)
|
|
249
|
+
size_sent, off = _read_u64(payload, off)
|
|
250
|
+
return FileCloseFrame(node_id=node_id, size_sent=size_sent)
|
|
251
|
+
|
|
252
|
+
elif tag == FrameTag.DIR_CLOSE:
|
|
253
|
+
node_id, off = _read_u32(payload, off)
|
|
254
|
+
return DirCloseFrame(node_id=node_id)
|
|
255
|
+
|
|
256
|
+
elif tag == FrameTag.SESSION_END:
|
|
257
|
+
scan_done, off = _read_u64(payload, off)
|
|
258
|
+
scan_est, off = _read_u64(payload, off)
|
|
259
|
+
body_done, off = _read_u64(payload, off)
|
|
260
|
+
body_est, off = _read_u64(payload, off)
|
|
261
|
+
sealed_val, off = _read_u8(payload, off)
|
|
262
|
+
return SessionEndFrame(
|
|
263
|
+
scan_done_units=scan_done,
|
|
264
|
+
scan_est_units=scan_est,
|
|
265
|
+
body_done_bytes=body_done,
|
|
266
|
+
body_est_bytes=body_est,
|
|
267
|
+
sealed=bool(sealed_val),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
elif tag == FrameTag.CLIENT_ABORT:
|
|
271
|
+
code, off = _read_string(payload, off)
|
|
272
|
+
reason, off = _read_string(payload, off)
|
|
273
|
+
return ClientAbortFrame(code=code, reason=reason)
|
|
274
|
+
|
|
275
|
+
else:
|
|
276
|
+
raise ValueError(f"unknown frame tag: {tag:#04x}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class FrameReader:
|
|
280
|
+
"""Incremental frame reader that buffers partial data from a streaming body.
|
|
281
|
+
|
|
282
|
+
Feed chunks via `feed(data)` and iterate decoded frames via `drain()`.
|
|
283
|
+
|
|
284
|
+
`max_frame_len` bounds the declared frame length: without it a corrupt or
|
|
285
|
+
malicious 4-byte prefix claiming a multi-gigabyte frame would grow the
|
|
286
|
+
buffer without limit while the reader waits for it to "complete".
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
# 1 MiB default: comfortably above MAX_CHUNK_BYTES (256 KiB) + headers
|
|
290
|
+
# and any NODE frame with a long UTF-8 name.
|
|
291
|
+
DEFAULT_MAX_FRAME_LEN = 1024 * 1024
|
|
292
|
+
|
|
293
|
+
def __init__(self, max_frame_len: int = DEFAULT_MAX_FRAME_LEN) -> None:
|
|
294
|
+
self._buf = bytearray()
|
|
295
|
+
self._max_frame_len = max_frame_len
|
|
296
|
+
|
|
297
|
+
def feed(self, data: bytes) -> None:
|
|
298
|
+
self._buf.extend(data)
|
|
299
|
+
|
|
300
|
+
def drain(self) -> list[Frame]:
|
|
301
|
+
frames: list[Frame] = []
|
|
302
|
+
pos = 0
|
|
303
|
+
total = len(self._buf)
|
|
304
|
+
|
|
305
|
+
while pos + 4 <= total:
|
|
306
|
+
frame_len = struct.unpack_from("!I", self._buf, pos)[0]
|
|
307
|
+
if frame_len > self._max_frame_len:
|
|
308
|
+
raise ValueError(
|
|
309
|
+
f"frame length {frame_len} exceeds limit {self._max_frame_len}"
|
|
310
|
+
)
|
|
311
|
+
if pos + 4 + frame_len > total:
|
|
312
|
+
break # incomplete frame
|
|
313
|
+
tag = self._buf[pos + 4]
|
|
314
|
+
# Copy payload to a bytes object so no memoryview holds the buffer
|
|
315
|
+
payload_bytes = bytes(self._buf[pos + 5 : pos + 4 + frame_len])
|
|
316
|
+
frames.append(decode_frame_payload(tag, memoryview(payload_bytes)))
|
|
317
|
+
pos += 4 + frame_len
|
|
318
|
+
|
|
319
|
+
if pos > 0:
|
|
320
|
+
del self._buf[:pos]
|
|
321
|
+
|
|
322
|
+
return frames
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# CRC-32C (Castagnoli) for checksum verification
|
|
327
|
+
#
|
|
328
|
+
# The C implementation from the `crc32c` package (SSE4.2/ARMv8 accelerated,
|
|
329
|
+
# ~GB/s) is a HARD dependency. A pure-Python fallback used to exist and was
|
|
330
|
+
# removed deliberately: it measured ~6 MB/s and blocked the event loop
|
|
331
|
+
# ~10 ms per 64 KiB chunk, silently throttling the whole server. Failing
|
|
332
|
+
# loudly at import beats degrading quietly in production.
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
import crc32c as _crc32c_native # type: ignore
|
|
337
|
+
except ImportError as exc: # pragma: no cover
|
|
338
|
+
raise ImportError(
|
|
339
|
+
"MFUP/2 requires the C-accelerated 'crc32c' package: pip install crc32c"
|
|
340
|
+
) from exc
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def crc32c(data: bytes | memoryview, initial: int = 0) -> int:
|
|
344
|
+
return _crc32c_native.crc32c(data, initial)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
CRC32C_IMPL = "native"
|