pairling 0.0.1 → 0.1.0
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.
- package/package.json +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- package/payload-manifest.json +255 -0
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mac-local PairDrop vault storage.
|
|
3
|
+
|
|
4
|
+
PairDrop stores user files under a Pairling-owned root and exposes files by
|
|
5
|
+
opaque ids, never by client-supplied paths. This module intentionally has no
|
|
6
|
+
HTTP dependency so daemon tests can exercise the storage contract directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import errno
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import sqlite3
|
|
18
|
+
import stat
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PairDropStoreError(ValueError):
|
|
27
|
+
def __init__(self, code: str, message: str | None = None):
|
|
28
|
+
super().__init__(message or code)
|
|
29
|
+
self.code = code
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _now_iso() -> str:
|
|
33
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _safe_display_name(filename: str) -> str:
|
|
37
|
+
base = os.path.basename(str(filename or "").strip())
|
|
38
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", base).strip("._")
|
|
39
|
+
if not safe:
|
|
40
|
+
return "upload.bin"
|
|
41
|
+
if len(safe) <= 120:
|
|
42
|
+
return safe
|
|
43
|
+
stem, dot, ext = safe.rpartition(".")
|
|
44
|
+
if dot and 1 <= len(ext) <= 12:
|
|
45
|
+
return stem[: 120 - len(ext) - 1] + "." + ext
|
|
46
|
+
return safe[:120]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _json_list(value: Any) -> str:
|
|
50
|
+
return json.dumps(value if isinstance(value, list) else [])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PairDropStore:
|
|
54
|
+
schema_version = 1
|
|
55
|
+
|
|
56
|
+
def __init__(self, root: Path):
|
|
57
|
+
self.root = Path(root).expanduser()
|
|
58
|
+
self.objects_dir = self.root / "objects"
|
|
59
|
+
self.partials_dir = self.root / "partials"
|
|
60
|
+
self.thumbnails_dir = self.root / "thumbnails"
|
|
61
|
+
self.exports_dir = self.root / "exports"
|
|
62
|
+
self.db_path = self.root / "index.sqlite"
|
|
63
|
+
self.audit_path = self.root / "audit.jsonl"
|
|
64
|
+
self._ensure_root()
|
|
65
|
+
|
|
66
|
+
def _ensure_root(self) -> None:
|
|
67
|
+
for path in [
|
|
68
|
+
self.root,
|
|
69
|
+
self.objects_dir,
|
|
70
|
+
self.partials_dir,
|
|
71
|
+
self.thumbnails_dir,
|
|
72
|
+
self.exports_dir,
|
|
73
|
+
]:
|
|
74
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
try:
|
|
76
|
+
# PairDrop stores private user files; the vault root must not be world-readable.
|
|
77
|
+
os.chmod(self.root, 0o700) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
with self._connect() as conn:
|
|
81
|
+
self._ensure_schema(conn)
|
|
82
|
+
|
|
83
|
+
@contextmanager
|
|
84
|
+
def _connect(self) -> Iterator[sqlite3.Connection]:
|
|
85
|
+
conn = sqlite3.connect(str(self.db_path))
|
|
86
|
+
conn.row_factory = sqlite3.Row
|
|
87
|
+
try:
|
|
88
|
+
yield conn
|
|
89
|
+
conn.commit()
|
|
90
|
+
except Exception:
|
|
91
|
+
conn.rollback()
|
|
92
|
+
raise
|
|
93
|
+
finally:
|
|
94
|
+
conn.close()
|
|
95
|
+
|
|
96
|
+
def _ensure_schema(self, conn: sqlite3.Connection) -> None:
|
|
97
|
+
conn.execute(
|
|
98
|
+
"""
|
|
99
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
100
|
+
id TEXT PRIMARY KEY,
|
|
101
|
+
parent_id TEXT,
|
|
102
|
+
kind TEXT NOT NULL,
|
|
103
|
+
display_name TEXT NOT NULL,
|
|
104
|
+
original_name TEXT NOT NULL,
|
|
105
|
+
content_type TEXT NOT NULL,
|
|
106
|
+
byte_size INTEGER NOT NULL,
|
|
107
|
+
sha256 TEXT,
|
|
108
|
+
storage_relpath TEXT,
|
|
109
|
+
source_device_id TEXT,
|
|
110
|
+
source_install_id TEXT,
|
|
111
|
+
source_route TEXT,
|
|
112
|
+
created_at TEXT NOT NULL,
|
|
113
|
+
updated_at TEXT NOT NULL,
|
|
114
|
+
deleted_at TEXT,
|
|
115
|
+
last_opened_at TEXT,
|
|
116
|
+
session_hint TEXT,
|
|
117
|
+
tags_json TEXT NOT NULL DEFAULT '[]'
|
|
118
|
+
)
|
|
119
|
+
"""
|
|
120
|
+
)
|
|
121
|
+
conn.execute(
|
|
122
|
+
"""
|
|
123
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
124
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
125
|
+
type TEXT NOT NULL,
|
|
126
|
+
file_id TEXT,
|
|
127
|
+
created_at TEXT NOT NULL,
|
|
128
|
+
summary_json TEXT NOT NULL
|
|
129
|
+
)
|
|
130
|
+
"""
|
|
131
|
+
)
|
|
132
|
+
conn.execute(
|
|
133
|
+
"""
|
|
134
|
+
CREATE TABLE IF NOT EXISTS upload_sessions (
|
|
135
|
+
upload_id TEXT PRIMARY KEY,
|
|
136
|
+
file_id TEXT,
|
|
137
|
+
display_name TEXT NOT NULL,
|
|
138
|
+
original_name TEXT NOT NULL,
|
|
139
|
+
content_type TEXT NOT NULL,
|
|
140
|
+
total_byte_count INTEGER NOT NULL,
|
|
141
|
+
expected_sha256 TEXT NOT NULL,
|
|
142
|
+
verified_offset INTEGER NOT NULL DEFAULT 0,
|
|
143
|
+
source_device_id TEXT,
|
|
144
|
+
source_install_id TEXT,
|
|
145
|
+
source_route TEXT,
|
|
146
|
+
state TEXT NOT NULL,
|
|
147
|
+
last_error TEXT,
|
|
148
|
+
created_at TEXT NOT NULL,
|
|
149
|
+
updated_at TEXT NOT NULL,
|
|
150
|
+
expires_at TEXT NOT NULL
|
|
151
|
+
)
|
|
152
|
+
"""
|
|
153
|
+
)
|
|
154
|
+
conn.execute(
|
|
155
|
+
"""
|
|
156
|
+
CREATE TABLE IF NOT EXISTS upload_chunks (
|
|
157
|
+
upload_id TEXT NOT NULL,
|
|
158
|
+
idempotency_key TEXT NOT NULL,
|
|
159
|
+
offset INTEGER NOT NULL,
|
|
160
|
+
byte_count INTEGER NOT NULL,
|
|
161
|
+
sha256 TEXT NOT NULL,
|
|
162
|
+
created_at TEXT NOT NULL,
|
|
163
|
+
PRIMARY KEY (upload_id, idempotency_key)
|
|
164
|
+
)
|
|
165
|
+
"""
|
|
166
|
+
)
|
|
167
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_pairdrop_files_deleted ON files(deleted_at)")
|
|
168
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_pairdrop_files_created ON files(created_at)")
|
|
169
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_pairdrop_upload_sessions_state ON upload_sessions(state)")
|
|
170
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_pairdrop_upload_sessions_expires ON upload_sessions(expires_at)")
|
|
171
|
+
|
|
172
|
+
def upload_bytes(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
filename: str,
|
|
176
|
+
content_type: str,
|
|
177
|
+
data: bytes,
|
|
178
|
+
source_device_id: str,
|
|
179
|
+
source_install_id: str,
|
|
180
|
+
source_route: str = "pairling-connectd",
|
|
181
|
+
session_hint: str = "",
|
|
182
|
+
expected_sha256: str | None = None,
|
|
183
|
+
parent_id: str | None = None,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
if not data:
|
|
186
|
+
raise PairDropStoreError("empty_body")
|
|
187
|
+
display_name = _safe_display_name(filename)
|
|
188
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
189
|
+
if expected_sha256 and expected_sha256.lower() != digest:
|
|
190
|
+
raise PairDropStoreError("sha256_mismatch")
|
|
191
|
+
if parent_id:
|
|
192
|
+
parent = self.get_file(parent_id)
|
|
193
|
+
if parent.get("kind") != "folder":
|
|
194
|
+
raise PairDropStoreError("bad_parent")
|
|
195
|
+
|
|
196
|
+
file_id = "pd_" + secrets.token_hex(16)
|
|
197
|
+
relpath = Path("objects") / digest[:2] / f"{file_id}.blob"
|
|
198
|
+
partial = self.partials_dir / f"{file_id}.partial"
|
|
199
|
+
target = self.root / relpath
|
|
200
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
partial.write_bytes(data)
|
|
202
|
+
os.replace(partial, target)
|
|
203
|
+
now = _now_iso()
|
|
204
|
+
with self._connect() as conn:
|
|
205
|
+
self._ensure_schema(conn)
|
|
206
|
+
conn.execute(
|
|
207
|
+
"""
|
|
208
|
+
INSERT INTO files (
|
|
209
|
+
id, parent_id, kind, display_name, original_name, content_type,
|
|
210
|
+
byte_size, sha256, storage_relpath, source_device_id,
|
|
211
|
+
source_install_id, source_route, created_at, updated_at,
|
|
212
|
+
deleted_at, last_opened_at, session_hint, tags_json
|
|
213
|
+
) VALUES (?, ?, 'file', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)
|
|
214
|
+
""",
|
|
215
|
+
(
|
|
216
|
+
file_id,
|
|
217
|
+
parent_id,
|
|
218
|
+
display_name,
|
|
219
|
+
str(filename or ""),
|
|
220
|
+
content_type or "application/octet-stream",
|
|
221
|
+
len(data),
|
|
222
|
+
digest,
|
|
223
|
+
str(relpath),
|
|
224
|
+
source_device_id,
|
|
225
|
+
source_install_id,
|
|
226
|
+
source_route,
|
|
227
|
+
now,
|
|
228
|
+
now,
|
|
229
|
+
session_hint,
|
|
230
|
+
_json_list([]),
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
self._record_event(conn, "created", file_id, {
|
|
234
|
+
"byte_size": len(data),
|
|
235
|
+
"content_type": content_type or "application/octet-stream",
|
|
236
|
+
"sha256": digest,
|
|
237
|
+
})
|
|
238
|
+
conn.commit()
|
|
239
|
+
item = self.get_file(file_id)
|
|
240
|
+
self._audit("file.created", {
|
|
241
|
+
"file_id": file_id,
|
|
242
|
+
"byte_size": len(data),
|
|
243
|
+
"content_type": content_type or "application/octet-stream",
|
|
244
|
+
"sha256": digest,
|
|
245
|
+
})
|
|
246
|
+
return item
|
|
247
|
+
|
|
248
|
+
def create_upload_session(
|
|
249
|
+
self,
|
|
250
|
+
*,
|
|
251
|
+
filename: str,
|
|
252
|
+
content_type: str,
|
|
253
|
+
total_byte_count: int,
|
|
254
|
+
expected_sha256: str,
|
|
255
|
+
source_device_id: str,
|
|
256
|
+
source_install_id: str,
|
|
257
|
+
source_route: str = "pairling-connectd",
|
|
258
|
+
expires_in_seconds: int = 24 * 60 * 60,
|
|
259
|
+
) -> dict[str, Any]:
|
|
260
|
+
total = int(total_byte_count)
|
|
261
|
+
digest = str(expected_sha256 or "").strip().lower()
|
|
262
|
+
if total <= 0:
|
|
263
|
+
raise PairDropStoreError("bad_total_byte_count")
|
|
264
|
+
if not re.fullmatch(r"[a-f0-9]{64}", digest):
|
|
265
|
+
raise PairDropStoreError("bad_expected_sha256")
|
|
266
|
+
upload_id = "pu_" + secrets.token_hex(16)
|
|
267
|
+
display_name = _safe_display_name(filename)
|
|
268
|
+
now = _now_iso()
|
|
269
|
+
expires_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + max(60, int(expires_in_seconds))))
|
|
270
|
+
with self._connect() as conn:
|
|
271
|
+
self._ensure_schema(conn)
|
|
272
|
+
conn.execute(
|
|
273
|
+
"""
|
|
274
|
+
INSERT INTO upload_sessions (
|
|
275
|
+
upload_id, file_id, display_name, original_name, content_type,
|
|
276
|
+
total_byte_count, expected_sha256, verified_offset,
|
|
277
|
+
source_device_id, source_install_id, source_route, state,
|
|
278
|
+
last_error, created_at, updated_at, expires_at
|
|
279
|
+
) VALUES (?, NULL, ?, ?, ?, ?, ?, 0, ?, ?, ?, 'created', NULL, ?, ?, ?)
|
|
280
|
+
""",
|
|
281
|
+
(
|
|
282
|
+
upload_id,
|
|
283
|
+
display_name,
|
|
284
|
+
str(filename or ""),
|
|
285
|
+
content_type or "application/octet-stream",
|
|
286
|
+
total,
|
|
287
|
+
digest,
|
|
288
|
+
source_device_id,
|
|
289
|
+
source_install_id,
|
|
290
|
+
source_route,
|
|
291
|
+
now,
|
|
292
|
+
now,
|
|
293
|
+
expires_at,
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
self._record_event(conn, "upload_session_created", None, {
|
|
297
|
+
"upload_id": upload_id,
|
|
298
|
+
"byte_size": total,
|
|
299
|
+
"content_type": content_type or "application/octet-stream",
|
|
300
|
+
})
|
|
301
|
+
conn.commit()
|
|
302
|
+
self._audit("upload_session.created", {
|
|
303
|
+
"upload_id": upload_id,
|
|
304
|
+
"byte_size": total,
|
|
305
|
+
"content_type": content_type or "application/octet-stream",
|
|
306
|
+
})
|
|
307
|
+
return self.get_upload_session(upload_id)
|
|
308
|
+
|
|
309
|
+
def get_upload_session(self, upload_id: str) -> dict[str, Any]:
|
|
310
|
+
if not self._valid_upload_id(upload_id):
|
|
311
|
+
raise PairDropStoreError("bad_upload_id")
|
|
312
|
+
with self._connect() as conn:
|
|
313
|
+
self._ensure_schema(conn)
|
|
314
|
+
row = conn.execute("SELECT * FROM upload_sessions WHERE upload_id = ?", (upload_id,)).fetchone()
|
|
315
|
+
if row is None:
|
|
316
|
+
raise PairDropStoreError("upload_not_found")
|
|
317
|
+
return self._public_upload_row(row)
|
|
318
|
+
|
|
319
|
+
def write_upload_chunk(
|
|
320
|
+
self,
|
|
321
|
+
upload_id: str,
|
|
322
|
+
*,
|
|
323
|
+
offset: int,
|
|
324
|
+
data: bytes,
|
|
325
|
+
chunk_sha256: str,
|
|
326
|
+
idempotency_key: str,
|
|
327
|
+
source_device_id: str,
|
|
328
|
+
source_install_id: str,
|
|
329
|
+
) -> dict[str, Any]:
|
|
330
|
+
if not self._valid_upload_id(upload_id):
|
|
331
|
+
raise PairDropStoreError("bad_upload_id")
|
|
332
|
+
if not data:
|
|
333
|
+
raise PairDropStoreError("empty_chunk")
|
|
334
|
+
offset = int(offset)
|
|
335
|
+
if offset < 0:
|
|
336
|
+
raise PairDropStoreError("bad_offset")
|
|
337
|
+
chunk_hash = str(chunk_sha256 or "").strip().lower()
|
|
338
|
+
if not re.fullmatch(r"[a-f0-9]{64}", chunk_hash):
|
|
339
|
+
raise PairDropStoreError("bad_chunk_sha256")
|
|
340
|
+
actual_hash = hashlib.sha256(data).hexdigest()
|
|
341
|
+
if actual_hash != chunk_hash:
|
|
342
|
+
raise PairDropStoreError("chunk_hash_mismatch")
|
|
343
|
+
idem = str(idempotency_key or "").strip()
|
|
344
|
+
if not idem or len(idem) > 160:
|
|
345
|
+
raise PairDropStoreError("bad_idempotency_key")
|
|
346
|
+
|
|
347
|
+
with self._connect() as conn:
|
|
348
|
+
self._ensure_schema(conn)
|
|
349
|
+
row = conn.execute("SELECT * FROM upload_sessions WHERE upload_id = ?", (upload_id,)).fetchone()
|
|
350
|
+
if row is None:
|
|
351
|
+
raise PairDropStoreError("upload_not_found")
|
|
352
|
+
session = self._public_upload_row(row)
|
|
353
|
+
self._assert_upload_source(session, source_device_id, source_install_id)
|
|
354
|
+
if session["state"] in {"committed", "cancelled", "expired", "failed_terminal"}:
|
|
355
|
+
raise PairDropStoreError("upload_not_writable")
|
|
356
|
+
|
|
357
|
+
previous = conn.execute(
|
|
358
|
+
"SELECT * FROM upload_chunks WHERE upload_id = ? AND idempotency_key = ?",
|
|
359
|
+
(upload_id, idem),
|
|
360
|
+
).fetchone()
|
|
361
|
+
if previous is not None:
|
|
362
|
+
if previous["offset"] != offset or previous["byte_count"] != len(data) or previous["sha256"] != chunk_hash:
|
|
363
|
+
raise PairDropStoreError("idempotency_conflict")
|
|
364
|
+
if self._partial_range_hash(upload_id, offset, len(data)) != chunk_hash:
|
|
365
|
+
raise PairDropStoreError("chunk_mismatch")
|
|
366
|
+
return {
|
|
367
|
+
**session,
|
|
368
|
+
"idempotent": True,
|
|
369
|
+
"verified_offset": max(session["verified_offset"], offset + len(data)),
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
verified_offset = int(session["verified_offset"] or 0)
|
|
373
|
+
if offset < verified_offset:
|
|
374
|
+
if self._partial_range_hash(upload_id, offset, len(data)) == chunk_hash:
|
|
375
|
+
return {**session, "idempotent": True}
|
|
376
|
+
raise PairDropStoreError("chunk_mismatch")
|
|
377
|
+
if offset != verified_offset:
|
|
378
|
+
raise PairDropStoreError("unexpected_offset")
|
|
379
|
+
if offset + len(data) > int(session["total_byte_count"]):
|
|
380
|
+
raise PairDropStoreError("chunk_exceeds_total")
|
|
381
|
+
|
|
382
|
+
self._write_partial_range(upload_id, offset, data)
|
|
383
|
+
new_offset = offset + len(data)
|
|
384
|
+
now = _now_iso()
|
|
385
|
+
conn.execute(
|
|
386
|
+
"""
|
|
387
|
+
INSERT INTO upload_chunks (
|
|
388
|
+
upload_id, idempotency_key, offset, byte_count, sha256, created_at
|
|
389
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
390
|
+
""",
|
|
391
|
+
(upload_id, idem, offset, len(data), chunk_hash, now),
|
|
392
|
+
)
|
|
393
|
+
conn.execute(
|
|
394
|
+
"""
|
|
395
|
+
UPDATE upload_sessions
|
|
396
|
+
SET verified_offset = ?, state = 'receiving', updated_at = ?, last_error = NULL
|
|
397
|
+
WHERE upload_id = ?
|
|
398
|
+
""",
|
|
399
|
+
(new_offset, now, upload_id),
|
|
400
|
+
)
|
|
401
|
+
self._record_event(conn, "upload_session_progress", None, {
|
|
402
|
+
"upload_id": upload_id,
|
|
403
|
+
"verified_offset": new_offset,
|
|
404
|
+
})
|
|
405
|
+
conn.commit()
|
|
406
|
+
|
|
407
|
+
self._audit("upload_session.chunk", {
|
|
408
|
+
"upload_id": upload_id,
|
|
409
|
+
"offset": offset,
|
|
410
|
+
"byte_count": len(data),
|
|
411
|
+
})
|
|
412
|
+
updated = self.get_upload_session(upload_id)
|
|
413
|
+
return {**updated, "idempotent": False}
|
|
414
|
+
|
|
415
|
+
def complete_upload_session(
|
|
416
|
+
self,
|
|
417
|
+
upload_id: str,
|
|
418
|
+
*,
|
|
419
|
+
source_device_id: str,
|
|
420
|
+
source_install_id: str,
|
|
421
|
+
) -> dict[str, Any]:
|
|
422
|
+
session = self.get_upload_session(upload_id)
|
|
423
|
+
self._assert_upload_source(session, source_device_id, source_install_id)
|
|
424
|
+
if session["state"] == "committed" and session.get("file_id"):
|
|
425
|
+
return {"ok": True, "state": "committed", "file": self.get_file(session["file_id"])}
|
|
426
|
+
if session["state"] in {"cancelled", "expired", "failed_terminal"}:
|
|
427
|
+
raise PairDropStoreError("upload_not_completable")
|
|
428
|
+
|
|
429
|
+
partial = self._partial_path(upload_id)
|
|
430
|
+
if partial.is_symlink():
|
|
431
|
+
self._mark_upload_error(upload_id, "failed_retryable", "missing_partial")
|
|
432
|
+
raise PairDropStoreError("missing_partial")
|
|
433
|
+
if not partial.is_file():
|
|
434
|
+
recovered = self._recover_completed_upload_session(session)
|
|
435
|
+
if recovered is not None:
|
|
436
|
+
return recovered
|
|
437
|
+
self._mark_upload_error(upload_id, "failed_retryable", "missing_partial")
|
|
438
|
+
raise PairDropStoreError("missing_partial")
|
|
439
|
+
byte_size = partial.stat().st_size
|
|
440
|
+
if byte_size != int(session["total_byte_count"]):
|
|
441
|
+
self._mark_upload_error(upload_id, "failed_retryable", "byte_count_mismatch")
|
|
442
|
+
raise PairDropStoreError("byte_count_mismatch")
|
|
443
|
+
digest = self._sha256_file(partial)
|
|
444
|
+
if digest != session["expected_sha256"]:
|
|
445
|
+
self._mark_upload_error(upload_id, "failed_terminal", "sha256_mismatch")
|
|
446
|
+
raise PairDropStoreError("sha256_mismatch")
|
|
447
|
+
|
|
448
|
+
file_id = session["file_id"] if self._valid_id(str(session.get("file_id") or "")) else "pd_" + secrets.token_hex(16)
|
|
449
|
+
relpath = Path("objects") / digest[:2] / f"{file_id}.blob"
|
|
450
|
+
target = self.root / relpath
|
|
451
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
now = _now_iso()
|
|
453
|
+
with self._connect() as conn:
|
|
454
|
+
self._ensure_schema(conn)
|
|
455
|
+
conn.execute(
|
|
456
|
+
"""
|
|
457
|
+
UPDATE upload_sessions
|
|
458
|
+
SET file_id = ?, state = 'completing', updated_at = ?, last_error = NULL
|
|
459
|
+
WHERE upload_id = ?
|
|
460
|
+
""",
|
|
461
|
+
(file_id, now, upload_id),
|
|
462
|
+
)
|
|
463
|
+
conn.commit()
|
|
464
|
+
os.replace(partial, target)
|
|
465
|
+
with self._connect() as conn:
|
|
466
|
+
self._ensure_schema(conn)
|
|
467
|
+
conn.execute(
|
|
468
|
+
"""
|
|
469
|
+
INSERT INTO files (
|
|
470
|
+
id, parent_id, kind, display_name, original_name, content_type,
|
|
471
|
+
byte_size, sha256, storage_relpath, source_device_id,
|
|
472
|
+
source_install_id, source_route, created_at, updated_at,
|
|
473
|
+
deleted_at, last_opened_at, session_hint, tags_json
|
|
474
|
+
) VALUES (?, NULL, 'file', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, '', ?)
|
|
475
|
+
""",
|
|
476
|
+
(
|
|
477
|
+
file_id,
|
|
478
|
+
session["display_name"],
|
|
479
|
+
session["original_name"],
|
|
480
|
+
session["content_type"],
|
|
481
|
+
byte_size,
|
|
482
|
+
digest,
|
|
483
|
+
str(relpath),
|
|
484
|
+
session["source_device_id"],
|
|
485
|
+
session["source_install_id"],
|
|
486
|
+
session["source_route"],
|
|
487
|
+
now,
|
|
488
|
+
now,
|
|
489
|
+
_json_list([]),
|
|
490
|
+
),
|
|
491
|
+
)
|
|
492
|
+
conn.execute(
|
|
493
|
+
"""
|
|
494
|
+
UPDATE upload_sessions
|
|
495
|
+
SET file_id = ?, state = 'committed', verified_offset = ?,
|
|
496
|
+
updated_at = ?, last_error = NULL
|
|
497
|
+
WHERE upload_id = ?
|
|
498
|
+
""",
|
|
499
|
+
(file_id, byte_size, now, upload_id),
|
|
500
|
+
)
|
|
501
|
+
self._record_event(conn, "created", file_id, {
|
|
502
|
+
"byte_size": byte_size,
|
|
503
|
+
"content_type": session["content_type"],
|
|
504
|
+
"sha256": digest,
|
|
505
|
+
})
|
|
506
|
+
self._record_event(conn, "upload_session_committed", file_id, {"upload_id": upload_id})
|
|
507
|
+
conn.commit()
|
|
508
|
+
item = self.get_file(file_id)
|
|
509
|
+
self._audit("upload_session.committed", {
|
|
510
|
+
"upload_id": upload_id,
|
|
511
|
+
"file_id": file_id,
|
|
512
|
+
"byte_size": byte_size,
|
|
513
|
+
"content_type": session["content_type"],
|
|
514
|
+
"sha256": digest,
|
|
515
|
+
})
|
|
516
|
+
return {"ok": True, "state": "committed", "upload_id": upload_id, "file": item}
|
|
517
|
+
|
|
518
|
+
def _recover_completed_upload_session(self, session: dict[str, Any]) -> dict[str, Any] | None:
|
|
519
|
+
upload_id = str(session.get("upload_id") or "")
|
|
520
|
+
expected_sha256 = str(session.get("expected_sha256") or "")
|
|
521
|
+
total_byte_count = int(session.get("total_byte_count") or 0)
|
|
522
|
+
file_id = str(session.get("file_id") or "")
|
|
523
|
+
candidates: list[Path] = []
|
|
524
|
+
if self._valid_id(file_id):
|
|
525
|
+
candidates.append(self.objects_dir / expected_sha256[:2] / f"{file_id}.blob")
|
|
526
|
+
else:
|
|
527
|
+
candidates.extend((self.objects_dir / expected_sha256[:2]).glob("pd_*.blob"))
|
|
528
|
+
|
|
529
|
+
for candidate in candidates:
|
|
530
|
+
try:
|
|
531
|
+
if candidate.is_symlink() or not candidate.is_file():
|
|
532
|
+
continue
|
|
533
|
+
recovered_file_id = candidate.stem
|
|
534
|
+
if not self._valid_id(recovered_file_id):
|
|
535
|
+
continue
|
|
536
|
+
if candidate.stat().st_size != total_byte_count:
|
|
537
|
+
continue
|
|
538
|
+
if self._sha256_file(candidate) != expected_sha256:
|
|
539
|
+
continue
|
|
540
|
+
return self._commit_recovered_upload_session(session, recovered_file_id, candidate)
|
|
541
|
+
except FileNotFoundError:
|
|
542
|
+
continue
|
|
543
|
+
return None
|
|
544
|
+
|
|
545
|
+
def _commit_recovered_upload_session(self, session: dict[str, Any], file_id: str, object_path: Path) -> dict[str, Any]:
|
|
546
|
+
upload_id = str(session["upload_id"])
|
|
547
|
+
byte_size = int(session["total_byte_count"])
|
|
548
|
+
digest = str(session["expected_sha256"])
|
|
549
|
+
relpath = object_path.relative_to(self.root)
|
|
550
|
+
now = _now_iso()
|
|
551
|
+
with self._connect() as conn:
|
|
552
|
+
self._ensure_schema(conn)
|
|
553
|
+
existing = conn.execute("SELECT id FROM files WHERE id = ?", (file_id,)).fetchone()
|
|
554
|
+
if existing is None:
|
|
555
|
+
conn.execute(
|
|
556
|
+
"""
|
|
557
|
+
INSERT INTO files (
|
|
558
|
+
id, parent_id, kind, display_name, original_name, content_type,
|
|
559
|
+
byte_size, sha256, storage_relpath, source_device_id,
|
|
560
|
+
source_install_id, source_route, created_at, updated_at,
|
|
561
|
+
deleted_at, last_opened_at, session_hint, tags_json
|
|
562
|
+
) VALUES (?, NULL, 'file', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, '', ?)
|
|
563
|
+
""",
|
|
564
|
+
(
|
|
565
|
+
file_id,
|
|
566
|
+
session["display_name"],
|
|
567
|
+
session["original_name"],
|
|
568
|
+
session["content_type"],
|
|
569
|
+
byte_size,
|
|
570
|
+
digest,
|
|
571
|
+
str(relpath),
|
|
572
|
+
session["source_device_id"],
|
|
573
|
+
session["source_install_id"],
|
|
574
|
+
session["source_route"],
|
|
575
|
+
now,
|
|
576
|
+
now,
|
|
577
|
+
_json_list([]),
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
self._record_event(conn, "created", file_id, {
|
|
581
|
+
"byte_size": byte_size,
|
|
582
|
+
"content_type": session["content_type"],
|
|
583
|
+
"sha256": digest,
|
|
584
|
+
})
|
|
585
|
+
conn.execute(
|
|
586
|
+
"""
|
|
587
|
+
UPDATE upload_sessions
|
|
588
|
+
SET file_id = ?, state = 'committed', verified_offset = ?,
|
|
589
|
+
updated_at = ?, last_error = NULL
|
|
590
|
+
WHERE upload_id = ?
|
|
591
|
+
""",
|
|
592
|
+
(file_id, byte_size, now, upload_id),
|
|
593
|
+
)
|
|
594
|
+
self._record_event(conn, "upload_session_recovered", file_id, {"upload_id": upload_id})
|
|
595
|
+
conn.commit()
|
|
596
|
+
item = self.get_file(file_id)
|
|
597
|
+
self._audit("upload_session.recovered", {
|
|
598
|
+
"upload_id": upload_id,
|
|
599
|
+
"file_id": file_id,
|
|
600
|
+
"byte_size": byte_size,
|
|
601
|
+
"content_type": session["content_type"],
|
|
602
|
+
"sha256": digest,
|
|
603
|
+
})
|
|
604
|
+
return {"ok": True, "state": "committed", "upload_id": upload_id, "file": item}
|
|
605
|
+
|
|
606
|
+
def cancel_upload_session(
|
|
607
|
+
self,
|
|
608
|
+
upload_id: str,
|
|
609
|
+
*,
|
|
610
|
+
source_device_id: str,
|
|
611
|
+
source_install_id: str,
|
|
612
|
+
) -> dict[str, Any]:
|
|
613
|
+
session = self.get_upload_session(upload_id)
|
|
614
|
+
self._assert_upload_source(session, source_device_id, source_install_id)
|
|
615
|
+
if session["state"] == "committed":
|
|
616
|
+
raise PairDropStoreError("upload_already_committed")
|
|
617
|
+
now = _now_iso()
|
|
618
|
+
with self._connect() as conn:
|
|
619
|
+
self._ensure_schema(conn)
|
|
620
|
+
conn.execute(
|
|
621
|
+
"UPDATE upload_sessions SET state = 'cancelled', updated_at = ? WHERE upload_id = ?",
|
|
622
|
+
(now, upload_id),
|
|
623
|
+
)
|
|
624
|
+
self._record_event(conn, "upload_session_cancelled", None, {"upload_id": upload_id})
|
|
625
|
+
conn.commit()
|
|
626
|
+
self._audit("upload_session.cancelled", {"upload_id": upload_id})
|
|
627
|
+
return self.get_upload_session(upload_id)
|
|
628
|
+
|
|
629
|
+
def list_files(self, *, include_deleted: bool = False) -> list[dict[str, Any]]:
|
|
630
|
+
query = (
|
|
631
|
+
"SELECT * FROM files ORDER BY created_at DESC, id DESC"
|
|
632
|
+
if include_deleted
|
|
633
|
+
else "SELECT * FROM files WHERE deleted_at IS NULL ORDER BY created_at DESC, id DESC"
|
|
634
|
+
)
|
|
635
|
+
with self._connect() as conn:
|
|
636
|
+
self._ensure_schema(conn)
|
|
637
|
+
rows = conn.execute(query).fetchall()
|
|
638
|
+
return [self._public_row(row) for row in rows]
|
|
639
|
+
|
|
640
|
+
def get_file(self, file_id: str, *, include_deleted: bool = False) -> dict[str, Any]:
|
|
641
|
+
if not self._valid_id(file_id):
|
|
642
|
+
raise PairDropStoreError("bad_file_id")
|
|
643
|
+
with self._connect() as conn:
|
|
644
|
+
self._ensure_schema(conn)
|
|
645
|
+
row = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
|
|
646
|
+
if row is None:
|
|
647
|
+
raise PairDropStoreError("not_found")
|
|
648
|
+
if row["deleted_at"] and not include_deleted:
|
|
649
|
+
raise PairDropStoreError("deleted")
|
|
650
|
+
return self._public_row(row)
|
|
651
|
+
|
|
652
|
+
def delete_file(self, file_id: str) -> dict[str, Any]:
|
|
653
|
+
item = self.get_file(file_id)
|
|
654
|
+
now = _now_iso()
|
|
655
|
+
with self._connect() as conn:
|
|
656
|
+
self._ensure_schema(conn)
|
|
657
|
+
conn.execute(
|
|
658
|
+
"UPDATE files SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL",
|
|
659
|
+
(now, now, file_id),
|
|
660
|
+
)
|
|
661
|
+
self._record_event(conn, "deleted", file_id, {"byte_size": item.get("byte_size", 0)})
|
|
662
|
+
conn.commit()
|
|
663
|
+
self._audit("file.deleted", {"file_id": file_id, "byte_size": item.get("byte_size", 0)})
|
|
664
|
+
return {"ok": True, "id": file_id, "deleted_at": now}
|
|
665
|
+
|
|
666
|
+
def attach_descriptor(self, file_id: str, *, session_id: str = "") -> dict[str, Any]:
|
|
667
|
+
item = self.get_file(file_id)
|
|
668
|
+
path = self._object_path(item)
|
|
669
|
+
if not path.is_file():
|
|
670
|
+
raise PairDropStoreError("missing_object")
|
|
671
|
+
now = _now_iso()
|
|
672
|
+
with self._connect() as conn:
|
|
673
|
+
self._ensure_schema(conn)
|
|
674
|
+
conn.execute(
|
|
675
|
+
"UPDATE files SET last_opened_at = ?, updated_at = ? WHERE id = ?",
|
|
676
|
+
(now, now, file_id),
|
|
677
|
+
)
|
|
678
|
+
self._record_event(conn, "attached", file_id, {"session": bool(session_id)})
|
|
679
|
+
conn.commit()
|
|
680
|
+
self._audit("file.attached", {"file_id": file_id, "session": bool(session_id)})
|
|
681
|
+
return {
|
|
682
|
+
"ok": True,
|
|
683
|
+
"id": file_id,
|
|
684
|
+
"display_name": item["display_name"],
|
|
685
|
+
"content_type": item["content_type"],
|
|
686
|
+
"byte_size": item["byte_size"],
|
|
687
|
+
"sha256": item["sha256"],
|
|
688
|
+
"path": str(path),
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
def download_descriptor(self, file_id: str) -> dict[str, Any]:
|
|
692
|
+
item = self.get_file(file_id)
|
|
693
|
+
path = self._object_path(item)
|
|
694
|
+
if path.is_symlink() or not path.is_file():
|
|
695
|
+
raise PairDropStoreError("missing_object")
|
|
696
|
+
resolved_root = self.root.resolve()
|
|
697
|
+
resolved_path = path.resolve()
|
|
698
|
+
if not str(resolved_path).startswith(str(resolved_root) + os.sep):
|
|
699
|
+
raise PairDropStoreError("object_escape")
|
|
700
|
+
stat_result = path.stat()
|
|
701
|
+
if int(item.get("byte_size") or 0) != stat_result.st_size:
|
|
702
|
+
raise PairDropStoreError("byte_size_mismatch")
|
|
703
|
+
now = _now_iso()
|
|
704
|
+
with self._connect() as conn:
|
|
705
|
+
self._ensure_schema(conn)
|
|
706
|
+
conn.execute(
|
|
707
|
+
"UPDATE files SET last_opened_at = ?, updated_at = ? WHERE id = ?",
|
|
708
|
+
(now, now, file_id),
|
|
709
|
+
)
|
|
710
|
+
self._record_event(conn, "downloaded", file_id, {"byte_size": item.get("byte_size", 0)})
|
|
711
|
+
conn.commit()
|
|
712
|
+
self._audit("file.downloaded", {"file_id": file_id, "byte_size": item.get("byte_size", 0)})
|
|
713
|
+
updated = self.get_file(file_id)
|
|
714
|
+
return {"item": updated, "path": path}
|
|
715
|
+
|
|
716
|
+
def events_since(self, seq: int = 0, limit: int = 100) -> list[dict[str, Any]]:
|
|
717
|
+
seq = max(0, int(seq or 0))
|
|
718
|
+
limit = max(1, min(int(limit or 100), 500))
|
|
719
|
+
with self._connect() as conn:
|
|
720
|
+
self._ensure_schema(conn)
|
|
721
|
+
rows = conn.execute(
|
|
722
|
+
"SELECT * FROM events WHERE seq > ? ORDER BY seq ASC LIMIT ?",
|
|
723
|
+
(seq, limit),
|
|
724
|
+
).fetchall()
|
|
725
|
+
return [
|
|
726
|
+
{
|
|
727
|
+
"seq": row["seq"],
|
|
728
|
+
"type": row["type"],
|
|
729
|
+
"file_id": row["file_id"],
|
|
730
|
+
"created_at": row["created_at"],
|
|
731
|
+
"summary": json.loads(row["summary_json"] or "{}"),
|
|
732
|
+
}
|
|
733
|
+
for row in rows
|
|
734
|
+
]
|
|
735
|
+
|
|
736
|
+
def cleanup_partials(self, *, older_than_seconds: int = 3600) -> dict[str, Any]:
|
|
737
|
+
cutoff = time.time() - max(0, older_than_seconds)
|
|
738
|
+
removed = 0
|
|
739
|
+
skipped_symlinks = 0
|
|
740
|
+
for path in self.partials_dir.glob("*.partial"):
|
|
741
|
+
try:
|
|
742
|
+
stat = path.lstat()
|
|
743
|
+
if path.is_symlink():
|
|
744
|
+
skipped_symlinks += 1
|
|
745
|
+
continue
|
|
746
|
+
if stat.st_mtime < cutoff:
|
|
747
|
+
path.unlink()
|
|
748
|
+
removed += 1
|
|
749
|
+
except FileNotFoundError:
|
|
750
|
+
continue
|
|
751
|
+
expired = 0
|
|
752
|
+
now = _now_iso()
|
|
753
|
+
with self._connect() as conn:
|
|
754
|
+
self._ensure_schema(conn)
|
|
755
|
+
rows = conn.execute(
|
|
756
|
+
"""
|
|
757
|
+
SELECT upload_id FROM upload_sessions
|
|
758
|
+
WHERE state NOT IN ('committed', 'cancelled', 'expired')
|
|
759
|
+
AND expires_at <= ?
|
|
760
|
+
""",
|
|
761
|
+
(now,),
|
|
762
|
+
).fetchall()
|
|
763
|
+
for row in rows:
|
|
764
|
+
conn.execute(
|
|
765
|
+
"UPDATE upload_sessions SET state = 'expired', updated_at = ? WHERE upload_id = ?",
|
|
766
|
+
(now, row["upload_id"]),
|
|
767
|
+
)
|
|
768
|
+
expired += 1
|
|
769
|
+
conn.commit()
|
|
770
|
+
self._audit("partials.cleaned", {"removed": removed, "skipped_symlinks": skipped_symlinks, "expired_sessions": expired})
|
|
771
|
+
return {"ok": True, "removed": removed, "skipped_symlinks": skipped_symlinks, "expired_sessions": expired}
|
|
772
|
+
|
|
773
|
+
def _object_path(self, item: dict[str, Any]) -> Path:
|
|
774
|
+
relpath = str(item.get("storage_relpath") or "")
|
|
775
|
+
if relpath.startswith("/") or ".." in Path(relpath).parts:
|
|
776
|
+
raise PairDropStoreError("unsafe_object_path")
|
|
777
|
+
path = (self.root / relpath).resolve()
|
|
778
|
+
root = self.root.resolve()
|
|
779
|
+
if root not in path.parents and path != root:
|
|
780
|
+
raise PairDropStoreError("unsafe_object_path")
|
|
781
|
+
return path
|
|
782
|
+
|
|
783
|
+
def _partial_path(self, upload_id: str) -> Path:
|
|
784
|
+
if not self._valid_upload_id(upload_id):
|
|
785
|
+
raise PairDropStoreError("bad_upload_id")
|
|
786
|
+
path = self.partials_dir / f"{upload_id}.partial"
|
|
787
|
+
parent = path.parent.resolve()
|
|
788
|
+
if parent != self.partials_dir.resolve():
|
|
789
|
+
raise PairDropStoreError("unsafe_partial_path")
|
|
790
|
+
return path
|
|
791
|
+
|
|
792
|
+
def _write_partial_range(self, upload_id: str, offset: int, data: bytes) -> None:
|
|
793
|
+
path = self._partial_path(upload_id)
|
|
794
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
795
|
+
flags = os.O_RDWR | os.O_CREAT
|
|
796
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
797
|
+
if no_follow:
|
|
798
|
+
flags |= no_follow
|
|
799
|
+
fd = -1
|
|
800
|
+
try:
|
|
801
|
+
fd = os.open(str(path), flags, 0o600)
|
|
802
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
803
|
+
raise PairDropStoreError("unsafe_partial_path")
|
|
804
|
+
with os.fdopen(fd, "r+b", closefd=True) as handle:
|
|
805
|
+
fd = -1
|
|
806
|
+
handle.seek(offset)
|
|
807
|
+
handle.write(data)
|
|
808
|
+
handle.flush()
|
|
809
|
+
os.fsync(handle.fileno())
|
|
810
|
+
except OSError as exc:
|
|
811
|
+
if exc.errno in {errno.ELOOP, errno.EISDIR, errno.ENOTDIR}:
|
|
812
|
+
raise PairDropStoreError("unsafe_partial_path") from exc
|
|
813
|
+
raise
|
|
814
|
+
finally:
|
|
815
|
+
if fd >= 0:
|
|
816
|
+
os.close(fd)
|
|
817
|
+
|
|
818
|
+
def _partial_range_hash(self, upload_id: str, offset: int, byte_count: int) -> str:
|
|
819
|
+
path = self._partial_path(upload_id)
|
|
820
|
+
if path.is_symlink() or not path.is_file():
|
|
821
|
+
raise PairDropStoreError("missing_partial")
|
|
822
|
+
with path.open("rb") as handle:
|
|
823
|
+
handle.seek(offset)
|
|
824
|
+
data = handle.read(byte_count)
|
|
825
|
+
if len(data) != byte_count:
|
|
826
|
+
raise PairDropStoreError("chunk_mismatch")
|
|
827
|
+
return hashlib.sha256(data).hexdigest()
|
|
828
|
+
|
|
829
|
+
def _sha256_file(self, path: Path) -> str:
|
|
830
|
+
hasher = hashlib.sha256()
|
|
831
|
+
with path.open("rb") as handle:
|
|
832
|
+
while True:
|
|
833
|
+
chunk = handle.read(1024 * 1024)
|
|
834
|
+
if not chunk:
|
|
835
|
+
break
|
|
836
|
+
hasher.update(chunk)
|
|
837
|
+
return hasher.hexdigest()
|
|
838
|
+
|
|
839
|
+
def _assert_upload_source(self, session: dict[str, Any], device_id: str, install_id: str) -> None:
|
|
840
|
+
if session.get("source_device_id") != device_id or session.get("source_install_id") != install_id:
|
|
841
|
+
raise PairDropStoreError("wrong_source")
|
|
842
|
+
|
|
843
|
+
def _mark_upload_error(self, upload_id: str, state: str, error: str) -> None:
|
|
844
|
+
now = _now_iso()
|
|
845
|
+
with self._connect() as conn:
|
|
846
|
+
self._ensure_schema(conn)
|
|
847
|
+
conn.execute(
|
|
848
|
+
"UPDATE upload_sessions SET state = ?, last_error = ?, updated_at = ? WHERE upload_id = ?",
|
|
849
|
+
(state, error, now, upload_id),
|
|
850
|
+
)
|
|
851
|
+
conn.commit()
|
|
852
|
+
|
|
853
|
+
def _public_row(self, row: sqlite3.Row) -> dict[str, Any]:
|
|
854
|
+
return {
|
|
855
|
+
"id": row["id"],
|
|
856
|
+
"parent_id": row["parent_id"],
|
|
857
|
+
"kind": row["kind"],
|
|
858
|
+
"display_name": row["display_name"],
|
|
859
|
+
"content_type": row["content_type"],
|
|
860
|
+
"byte_size": row["byte_size"],
|
|
861
|
+
"sha256": row["sha256"],
|
|
862
|
+
"source_device_id": row["source_device_id"],
|
|
863
|
+
"source_install_id": row["source_install_id"],
|
|
864
|
+
"source_route": row["source_route"],
|
|
865
|
+
"created_at": row["created_at"],
|
|
866
|
+
"updated_at": row["updated_at"],
|
|
867
|
+
"deleted_at": row["deleted_at"],
|
|
868
|
+
"last_opened_at": row["last_opened_at"],
|
|
869
|
+
"session_hint": row["session_hint"],
|
|
870
|
+
"storage_relpath": row["storage_relpath"],
|
|
871
|
+
"tags": json.loads(row["tags_json"] or "[]"),
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
def _public_upload_row(self, row: sqlite3.Row) -> dict[str, Any]:
|
|
875
|
+
return {
|
|
876
|
+
"upload_id": row["upload_id"],
|
|
877
|
+
"file_id": row["file_id"],
|
|
878
|
+
"display_name": row["display_name"],
|
|
879
|
+
"original_name": row["original_name"],
|
|
880
|
+
"content_type": row["content_type"],
|
|
881
|
+
"total_byte_count": row["total_byte_count"],
|
|
882
|
+
"expected_sha256": row["expected_sha256"],
|
|
883
|
+
"verified_offset": row["verified_offset"],
|
|
884
|
+
"source_device_id": row["source_device_id"],
|
|
885
|
+
"source_install_id": row["source_install_id"],
|
|
886
|
+
"source_route": row["source_route"],
|
|
887
|
+
"state": row["state"],
|
|
888
|
+
"last_error": row["last_error"],
|
|
889
|
+
"created_at": row["created_at"],
|
|
890
|
+
"updated_at": row["updated_at"],
|
|
891
|
+
"expires_at": row["expires_at"],
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
def _record_event(self, conn: sqlite3.Connection, event_type: str, file_id: str | None, summary: dict[str, Any]) -> None:
|
|
895
|
+
conn.execute(
|
|
896
|
+
"INSERT INTO events (type, file_id, created_at, summary_json) VALUES (?, ?, ?, ?)",
|
|
897
|
+
(event_type, file_id, _now_iso(), json.dumps(summary, sort_keys=True)),
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
def _audit(self, event: str, detail: dict[str, Any]) -> None:
|
|
901
|
+
self.audit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
902
|
+
safe_detail = {
|
|
903
|
+
key: value for key, value in detail.items()
|
|
904
|
+
if key not in {"path", "body", "request_body", "contents"}
|
|
905
|
+
}
|
|
906
|
+
record = {
|
|
907
|
+
"ts": _now_iso(),
|
|
908
|
+
"event": event,
|
|
909
|
+
"detail": safe_detail,
|
|
910
|
+
}
|
|
911
|
+
with self.audit_path.open("a", encoding="utf-8") as handle:
|
|
912
|
+
handle.write(json.dumps(record, sort_keys=True) + "\n")
|
|
913
|
+
|
|
914
|
+
@staticmethod
|
|
915
|
+
def _valid_id(file_id: str) -> bool:
|
|
916
|
+
return bool(re.fullmatch(r"pd_[a-f0-9]{32}", str(file_id or "")))
|
|
917
|
+
|
|
918
|
+
@staticmethod
|
|
919
|
+
def _valid_upload_id(upload_id: str) -> bool:
|
|
920
|
+
return bool(re.fullmatch(r"pu_[a-f0-9]{32}", str(upload_id or "")))
|