fleetmesh-node 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.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,383 @@
1
+ """Content-Addressed Storage (CAS) for blobs and IPFS CIDv1 interoperability.
2
+
3
+ Spec reference: spec.html section "Content Addressing & CIDs" (lines 3470-3476) and "Blobs" (lines 3768-3776).
4
+ - Blossom blobs are SHA-256 addressed.
5
+ - Maps directly to IPFS Content Identifiers (CIDs):
6
+ multibase 'b' (RFC 4648 Base32 lowercase), CIDv1 (0x01), raw binary codec (0x55), multihash sha2-256 (0x12, 0x20).
7
+ - Standard CAS on-disk sharded directory layout: `blobs/data/<sha256[:2]>/<sha256>`
8
+ (fanout depth 1). The depth is configurable (0..8 two-hex-char levels) so an
9
+ operator may point the store at a tree another CAS already lays out the same
10
+ way (magpie: `<data>/objects/<ab>/<cd>/<sha>`, depth 2) - see docs/sidecars.md.
11
+ The depth a directory was created with is recorded in `blobs.sqlite` and a
12
+ store opened with a different depth refuses rather than silently 404 every blob.
13
+ - SQLite metadata index in `blobs.sqlite` tracking size, content-type, uploader attribution, and upload timestamps.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import os
19
+ import re
20
+ import tempfile
21
+ import time
22
+ from typing import Optional, TypedDict
23
+
24
+ from fleetmesh_core.identity.cid import cid_to_sha256, sha256_to_cid
25
+ from node.compartment.schema import Column, Index, Migration, Table
26
+ from node.storage import SqliteStore
27
+
28
+ HEX64_RE = re.compile(r'^[0-9a-f]{64}$')
29
+
30
+ __all__ = ['BlobDescriptor', 'BlobMetadataStore', 'BlobStore', 'cid_to_sha256', 'sha256_to_cid']
31
+
32
+
33
+ class BlobDescriptor(TypedDict, total=False):
34
+ """Metadata descriptor for a stored or indexed blob."""
35
+ sha256: str
36
+ size: int
37
+ type: str
38
+ uploaded: int
39
+ uploaded_by: Optional[str]
40
+ cid: str
41
+ pinned: bool
42
+ accessed_at: Optional[int]
43
+
44
+
45
+ class BlobMetadataStore(SqliteStore):
46
+ """Thread-safe SQLite metadata index for Content-Addressed Storage."""
47
+
48
+ SCHEMA = (
49
+ Table('blobs', (
50
+ Column('sha256', 'TEXT', primary_key=True),
51
+ Column('size', 'INTEGER', not_null=True),
52
+ Column('type', 'TEXT', not_null=True),
53
+ Column('uploaded_by', 'TEXT'),
54
+ Column('created_at', 'INTEGER', not_null=True),
55
+ Column('accessed_at', 'INTEGER'),
56
+ Column('pinned', 'INTEGER', default=0),
57
+ ), indices=(
58
+ Index('idx_blobs_uploaded', ('uploaded_by',)),
59
+ )),
60
+ Table('meta', (
61
+ Column('key', 'TEXT', primary_key=True),
62
+ Column('value', 'TEXT', not_null=True),
63
+ )),
64
+ )
65
+ # A blobs table from before the LRU existed lacks both columns; the index
66
+ # over them can only be built once they are there, so it is the last step.
67
+ MIGRATIONS = (
68
+ Migration(1, up='ALTER TABLE blobs ADD COLUMN accessed_at INTEGER', name='blobs_accessed_at'),
69
+ Migration(2, up='ALTER TABLE blobs ADD COLUMN pinned INTEGER DEFAULT 0', name='blobs_pinned'),
70
+ Migration(3, up='CREATE INDEX IF NOT EXISTS idx_blobs_lru ON blobs(pinned, accessed_at)', name='idx_blobs_lru'),
71
+ )
72
+
73
+ def meta_get(self, key: str) -> Optional[str]:
74
+ row = self._one('SELECT value FROM meta WHERE key = ?', (key,))
75
+ return None if row is None else str(row[0])
76
+
77
+ def meta_set_if_absent(self, key: str, value: str) -> str:
78
+ """Record ``value`` under ``key`` unless one is already there; return the stored one."""
79
+ self.execute('INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)', (key, value))
80
+ self.commit()
81
+ got = self.meta_get(key)
82
+ return value if got is None else got
83
+
84
+
85
+ class LayoutMismatch(RuntimeError):
86
+ """A blob directory was created with a different fanout depth than it is being opened with."""
87
+
88
+
89
+ MAX_FANOUT = 8
90
+ DEFAULT_FANOUT = 1
91
+
92
+
93
+ class BlobStore:
94
+ """Content-Addressed Storage engine for SHA-256 blobs and artifacts."""
95
+
96
+ def __init__(
97
+ self,
98
+ directory: str,
99
+ max_bytes: Optional[int] = None,
100
+ max_storage_bytes: Optional[int] = None,
101
+ fanout: int = DEFAULT_FANOUT,
102
+ data_dir: Optional[str] = None,
103
+ ) -> None:
104
+ """``directory`` holds ``blobs.sqlite``; blobs live under ``data_dir``
105
+ (default ``<directory>/data``) at ``fanout`` two-hex-char levels.
106
+
107
+ ``fanout``/``data_dir`` exist so a node may share a tree with another
108
+ CAS that uses the same sha256 layout. Shared disk is not a shared
109
+ catalogue - each index knows only what it wrote - and the other writer's
110
+ file modes/ownership must let this process read and unlink.
111
+ """
112
+ if not isinstance(fanout, int) or isinstance(fanout, bool) or not 0 <= fanout <= MAX_FANOUT:
113
+ raise ValueError(f'blob fanout must be an integer 0..{MAX_FANOUT}, got {fanout!r}')
114
+ self.dir: str = os.path.realpath(directory)
115
+ self.data_dir: str = os.path.realpath(data_dir) if data_dir else os.path.join(self.dir, 'data')
116
+ self.db_path: str = os.path.join(self.dir, 'blobs.sqlite')
117
+ self.max_bytes: Optional[int] = max_bytes
118
+ self.max_storage_bytes: Optional[int] = max_storage_bytes
119
+ self.fanout: int = fanout
120
+
121
+ os.makedirs(self.dir, exist_ok=True)
122
+ os.makedirs(self.data_dir, exist_ok=True)
123
+ self.db: BlobMetadataStore = BlobMetadataStore(self.db_path)
124
+ recorded = self.db.meta_set_if_absent('fanout', str(fanout))
125
+ if recorded != str(fanout):
126
+ self.db.close()
127
+ raise LayoutMismatch(
128
+ f'{self.db.safe_path} was created with fanout {recorded}; opening with {fanout} '
129
+ f'would hide every stored blob. Keep the recorded depth or point at a new directory.')
130
+ recorded_dir = self.db.meta_set_if_absent('data_dir', self.data_dir)
131
+ if recorded_dir != self.data_dir:
132
+ self.db.close()
133
+ raise LayoutMismatch(
134
+ f'{self.db.safe_path} indexes blobs under {recorded_dir}, not {self.data_dir}. '
135
+ f'Keep the recorded data_dir or point at a new directory.')
136
+
137
+ def _blob_path(self, sha256_hex: str) -> str:
138
+ h = sha256_hex.lower()
139
+ levels = [h[2 * i:2 * i + 2] for i in range(self.fanout)]
140
+ return os.path.join(self.data_dir, *levels, h)
141
+
142
+ def put(
143
+ self,
144
+ data: bytes,
145
+ content_type: str = 'application/octet-stream',
146
+ uploaded_by: Optional[str] = None,
147
+ now: Optional[int] = None,
148
+ ) -> BlobDescriptor:
149
+ """Store a blob by content hash. Returns normative descriptor."""
150
+ if not isinstance(data, (bytes, bytearray)):
151
+ raise TypeError("blob data must be bytes")
152
+
153
+ size = len(data)
154
+ if self.max_bytes is not None and size > self.max_bytes:
155
+ raise ValueError(f"blob size {size} exceeds maximum limit {self.max_bytes}")
156
+
157
+ h = hashlib.sha256(data).hexdigest().lower()
158
+ now_ts = int(now if now is not None else time.time())
159
+
160
+ # Enforce quota watermark with automatic LRU eviction
161
+ if self.max_storage_bytes is not None and not self.has(h):
162
+ cur_total = self.total_bytes()
163
+ if cur_total + size > self.max_storage_bytes:
164
+ needed = (cur_total + size) - self.max_storage_bytes
165
+ self.evict_lru(needed_bytes=needed)
166
+ if self.total_bytes() + size > self.max_storage_bytes:
167
+ raise ValueError(
168
+ f"CAS quota exceeded: cannot store {size} bytes (storage cap {self.max_storage_bytes} bytes)"
169
+ )
170
+
171
+ dest = self._blob_path(h)
172
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
173
+
174
+ # Write atomically via tempfile in same filesystem directory
175
+ if not os.path.exists(dest):
176
+ with tempfile.NamedTemporaryFile(dir=os.path.dirname(dest), delete=False) as tf:
177
+ tf.write(data)
178
+ tmp_name = tf.name
179
+ os.replace(tmp_name, dest)
180
+
181
+ # Record metadata preserving pinned state if existing
182
+ self.db.execute('''
183
+ INSERT INTO blobs (sha256, size, type, uploaded_by, created_at, accessed_at, pinned)
184
+ VALUES (?, ?, ?, ?, ?, ?, COALESCE((SELECT pinned FROM blobs WHERE sha256 = ?), 0))
185
+ ON CONFLICT(sha256) DO UPDATE SET
186
+ size=excluded.size,
187
+ type=excluded.type,
188
+ accessed_at=excluded.accessed_at
189
+ ''', (h, size, content_type, uploaded_by, now_ts, now_ts, h))
190
+ self.db.commit()
191
+
192
+ return {
193
+ 'sha256': h,
194
+ 'size': size,
195
+ 'type': content_type,
196
+ 'uploaded': now_ts,
197
+ 'uploaded_by': uploaded_by,
198
+ 'cid': sha256_to_cid(h),
199
+ }
200
+
201
+ def get(self, sha256_hex: str) -> Optional[bytes]:
202
+ """Read full blob bytes, verifying SHA-256 integrity."""
203
+ h = sha256_hex.lower()
204
+ if not HEX64_RE.match(h):
205
+ return None
206
+
207
+ path = self._blob_path(h)
208
+ if not os.path.exists(path):
209
+ return None
210
+
211
+ try:
212
+ with open(path, 'rb') as f:
213
+ data = f.read()
214
+ # Verify integrity
215
+ if hashlib.sha256(data).hexdigest().lower() != h:
216
+ os.unlink(path)
217
+ return None
218
+ try:
219
+ self.db.execute('UPDATE blobs SET accessed_at = ? WHERE sha256 = ?', (int(time.time()), h))
220
+ self.db.commit()
221
+ except Exception:
222
+ pass
223
+ return data
224
+ except OSError:
225
+ return None
226
+
227
+ def has(self, sha256_hex: str) -> bool:
228
+ """Check if blob exists on disk and is indexed."""
229
+ h = sha256_hex.lower()
230
+ if not HEX64_RE.match(h):
231
+ return False
232
+ return os.path.exists(self._blob_path(h))
233
+
234
+ def stat(self, sha256_hex: str) -> Optional[BlobDescriptor]:
235
+ """Retrieve metadata descriptor without loading blob content."""
236
+ h = sha256_hex.lower()
237
+ if not HEX64_RE.match(h):
238
+ return None
239
+
240
+ row = self.db._one('''
241
+ SELECT size, type, uploaded_by, created_at, pinned, accessed_at FROM blobs WHERE sha256 = ?
242
+ ''', (h,))
243
+
244
+ if not row:
245
+ if not os.path.exists(self._blob_path(h)):
246
+ return None
247
+ try:
248
+ sz = os.path.getsize(self._blob_path(h))
249
+ return {
250
+ 'sha256': h,
251
+ 'size': sz,
252
+ 'type': 'application/octet-stream',
253
+ 'uploaded': int(os.path.getmtime(self._blob_path(h))),
254
+ 'uploaded_by': None,
255
+ 'cid': sha256_to_cid(h),
256
+ 'pinned': False,
257
+ }
258
+ except OSError:
259
+ return None
260
+
261
+ size, ctype, uploaded_by, created_at, pinned, accessed_at = row
262
+ return {
263
+ 'sha256': h,
264
+ 'size': size,
265
+ 'type': ctype,
266
+ 'uploaded': created_at,
267
+ 'uploaded_by': uploaded_by,
268
+ 'cid': sha256_to_cid(h),
269
+ 'pinned': bool(pinned),
270
+ 'accessed_at': accessed_at,
271
+ }
272
+
273
+ def delete(self, sha256_hex: str) -> bool:
274
+ """Delete blob from disk and metadata index.
275
+
276
+ The digest is validated before it touches the filesystem: get/has
277
+ already refuse anything but 64 hex chars, and delete is the one
278
+ path here that unlinks, so '../..' must never reach _blob_path.
279
+ """
280
+ h = sha256_hex.lower()
281
+ if not HEX64_RE.match(h):
282
+ return False
283
+ path = self._blob_path(h)
284
+ deleted = False
285
+ if os.path.exists(path):
286
+ try:
287
+ os.unlink(path)
288
+ deleted = True
289
+ except OSError:
290
+ pass
291
+
292
+ cur = self.db.execute('DELETE FROM blobs WHERE sha256 = ?', (h,))
293
+ self.db.commit()
294
+ if cur.rowcount > 0:
295
+ deleted = True
296
+
297
+ return deleted
298
+
299
+ def list_by(
300
+ self,
301
+ uploaded_by: Optional[str] = None,
302
+ limit: int = 100,
303
+ offset: int = 0,
304
+ ) -> list[BlobDescriptor]:
305
+ """List stored blobs matching uploader pubkey."""
306
+ if uploaded_by:
307
+ rows = self.db._all('''
308
+ SELECT sha256, size, type, uploaded_by, created_at
309
+ FROM blobs WHERE uploaded_by = ?
310
+ ORDER BY created_at DESC LIMIT ? OFFSET ?
311
+ ''', (uploaded_by, limit, offset))
312
+ else:
313
+ rows = self.db._all('''
314
+ SELECT sha256, size, type, uploaded_by, created_at
315
+ FROM blobs ORDER BY created_at DESC LIMIT ? OFFSET ?
316
+ ''', (limit, offset))
317
+
318
+ out: list[BlobDescriptor] = []
319
+ for h, sz, ctype, uploader, ts in rows:
320
+ out.append({
321
+ 'sha256': h,
322
+ 'size': sz,
323
+ 'type': ctype,
324
+ 'uploaded': ts,
325
+ 'uploaded_by': uploader,
326
+ 'cid': sha256_to_cid(h),
327
+ })
328
+ return out
329
+
330
+ def total_bytes(self) -> int:
331
+ """Total size in bytes of all indexed blobs."""
332
+ row = self.db._one('SELECT SUM(size) FROM blobs')
333
+ return int((row and row[0]) or 0)
334
+
335
+ def count(self) -> int:
336
+ """Number of indexed blobs."""
337
+ row = self.db._one('SELECT COUNT(*) FROM blobs')
338
+ return int((row and row[0]) or 0)
339
+
340
+ def pin(self, sha256_hex: str) -> bool:
341
+ """Pin a blob to prevent LRU eviction."""
342
+ h = sha256_hex.lower()
343
+ if not HEX64_RE.match(h) or not self.has(h):
344
+ return False
345
+ cur = self.db.execute('UPDATE blobs SET pinned = 1 WHERE sha256 = ?', (h,))
346
+ self.db.commit()
347
+ return cur.rowcount > 0
348
+
349
+ def unpin(self, sha256_hex: str) -> bool:
350
+ """Unpin a blob, allowing LRU eviction."""
351
+ h = sha256_hex.lower()
352
+ if not HEX64_RE.match(h):
353
+ return False
354
+ cur = self.db.execute('UPDATE blobs SET pinned = 0 WHERE sha256 = ?', (h,))
355
+ self.db.commit()
356
+ return cur.rowcount > 0
357
+
358
+ def is_pinned(self, sha256_hex: str) -> bool:
359
+ """Check if blob is pinned against LRU eviction."""
360
+ h = sha256_hex.lower()
361
+ row = self.db._one('SELECT pinned FROM blobs WHERE sha256 = ?', (h,))
362
+ return bool(row and row[0])
363
+
364
+ def evict_lru(self, needed_bytes: int = 0) -> int:
365
+ """Evict oldest unpinned blobs by accessed_at timestamp to free needed_bytes.
366
+
367
+ Returns total bytes freed.
368
+ """
369
+ rows = self.db._all(
370
+ 'SELECT sha256, size FROM blobs WHERE pinned = 0 '
371
+ 'ORDER BY COALESCE(accessed_at, created_at) ASC, created_at ASC'
372
+ )
373
+ freed = 0
374
+ for h, sz in rows:
375
+ if needed_bytes > 0 and freed >= needed_bytes:
376
+ break
377
+ if self.delete(h):
378
+ freed += sz
379
+ return freed
380
+
381
+ def close(self) -> None:
382
+ """Safely close underlying metadata database."""
383
+ self.db.close()