astreum 0.2.29__py3-none-any.whl → 0.2.61__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.
- astreum/__init__.py +9 -1
- astreum/_communication/__init__.py +11 -0
- astreum/{models → _communication}/message.py +101 -64
- astreum/_communication/peer.py +23 -0
- astreum/_communication/ping.py +33 -0
- astreum/_communication/route.py +95 -0
- astreum/_communication/setup.py +322 -0
- astreum/_communication/util.py +42 -0
- astreum/_consensus/__init__.py +20 -0
- astreum/_consensus/account.py +95 -0
- astreum/_consensus/accounts.py +38 -0
- astreum/_consensus/block.py +311 -0
- astreum/_consensus/chain.py +66 -0
- astreum/_consensus/fork.py +100 -0
- astreum/_consensus/genesis.py +72 -0
- astreum/_consensus/receipt.py +136 -0
- astreum/_consensus/setup.py +115 -0
- astreum/_consensus/transaction.py +215 -0
- astreum/_consensus/workers/__init__.py +9 -0
- astreum/_consensus/workers/discovery.py +48 -0
- astreum/_consensus/workers/validation.py +125 -0
- astreum/_consensus/workers/verify.py +63 -0
- astreum/_lispeum/__init__.py +16 -0
- astreum/_lispeum/environment.py +13 -0
- astreum/_lispeum/expression.py +190 -0
- astreum/_lispeum/high_evaluation.py +236 -0
- astreum/_lispeum/low_evaluation.py +123 -0
- astreum/_lispeum/meter.py +18 -0
- astreum/_lispeum/parser.py +51 -0
- astreum/_lispeum/tokenizer.py +22 -0
- astreum/_node.py +198 -0
- astreum/_storage/__init__.py +7 -0
- astreum/_storage/atom.py +109 -0
- astreum/_storage/patricia.py +478 -0
- astreum/_storage/setup.py +35 -0
- astreum/models/block.py +48 -39
- astreum/node.py +755 -563
- astreum/utils/bytes.py +24 -0
- astreum/utils/integer.py +25 -0
- astreum/utils/logging.py +219 -0
- {astreum-0.2.29.dist-info → astreum-0.2.61.dist-info}/METADATA +50 -14
- astreum-0.2.61.dist-info/RECORD +57 -0
- astreum/lispeum/__init__.py +0 -2
- astreum/lispeum/environment.py +0 -40
- astreum/lispeum/expression.py +0 -86
- astreum/lispeum/parser.py +0 -41
- astreum/lispeum/tokenizer.py +0 -52
- astreum/models/account.py +0 -91
- astreum/models/accounts.py +0 -34
- astreum/models/transaction.py +0 -106
- astreum/relay/__init__.py +0 -0
- astreum/relay/peer.py +0 -9
- astreum/relay/route.py +0 -25
- astreum/relay/setup.py +0 -58
- astreum-0.2.29.dist-info/RECORD +0 -33
- {astreum-0.2.29.dist-info → astreum-0.2.61.dist-info}/WHEEL +0 -0
- {astreum-0.2.29.dist-info → astreum-0.2.61.dist-info}/licenses/LICENSE +0 -0
- {astreum-0.2.29.dist-info → astreum-0.2.61.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from typing import Dict, List, Union
|
|
2
|
+
from .expression import Expr, error_expr
|
|
3
|
+
from .meter import Meter
|
|
4
|
+
|
|
5
|
+
def tc_to_int(b: bytes) -> int:
|
|
6
|
+
"""bytes -> int using two's complement (width = len(b)*8)."""
|
|
7
|
+
if not b:
|
|
8
|
+
return 0
|
|
9
|
+
return int.from_bytes(b, "big", signed=True)
|
|
10
|
+
|
|
11
|
+
def int_to_tc(n: int, width_bytes: int) -> bytes:
|
|
12
|
+
"""int -> bytes (two's complement, fixed width)."""
|
|
13
|
+
if width_bytes <= 0:
|
|
14
|
+
return b"\x00"
|
|
15
|
+
return n.to_bytes(width_bytes, "big", signed=True)
|
|
16
|
+
|
|
17
|
+
def min_tc_width(n: int) -> int:
|
|
18
|
+
"""minimum bytes to store n in two's complement."""
|
|
19
|
+
if n == 0:
|
|
20
|
+
return 1
|
|
21
|
+
w = 1
|
|
22
|
+
while True:
|
|
23
|
+
try:
|
|
24
|
+
n.to_bytes(w, "big", signed=True)
|
|
25
|
+
return w
|
|
26
|
+
except OverflowError:
|
|
27
|
+
w += 1
|
|
28
|
+
|
|
29
|
+
def nand_bytes(a: bytes, b: bytes) -> bytes:
|
|
30
|
+
"""Bitwise NAND on two byte strings, zero-extending to max width."""
|
|
31
|
+
w = max(len(a), len(b), 1)
|
|
32
|
+
au = int.from_bytes(a.rjust(w, b"\x00"), "big", signed=False)
|
|
33
|
+
bu = int.from_bytes(b.rjust(w, b"\x00"), "big", signed=False)
|
|
34
|
+
mask = (1 << (w * 8)) - 1
|
|
35
|
+
resu = (~(au & bu)) & mask
|
|
36
|
+
return resu.to_bytes(w, "big", signed=False)
|
|
37
|
+
|
|
38
|
+
def low_eval(self, code: List[bytes], meter: Meter) -> Expr:
|
|
39
|
+
|
|
40
|
+
heap: Dict[bytes, bytes] = {}
|
|
41
|
+
|
|
42
|
+
stack: List[bytes] = []
|
|
43
|
+
pc = 0
|
|
44
|
+
|
|
45
|
+
while True:
|
|
46
|
+
if pc >= len(code):
|
|
47
|
+
if len(stack) != 1:
|
|
48
|
+
return error_expr("low_eval", "bad stack")
|
|
49
|
+
# wrap successful result as an Expr.Bytes
|
|
50
|
+
return Expr.Bytes(stack.pop())
|
|
51
|
+
|
|
52
|
+
tok = code[pc]
|
|
53
|
+
pc += 1
|
|
54
|
+
|
|
55
|
+
# ---------- ADD ----------
|
|
56
|
+
if tok == b"add":
|
|
57
|
+
if len(stack) < 2:
|
|
58
|
+
return error_expr("low_eval", "underflow")
|
|
59
|
+
b_b = stack.pop()
|
|
60
|
+
a_b = stack.pop()
|
|
61
|
+
a_i = tc_to_int(a_b)
|
|
62
|
+
b_i = tc_to_int(b_b)
|
|
63
|
+
res_i = a_i + b_i
|
|
64
|
+
width = max(len(a_b), len(b_b), min_tc_width(res_i))
|
|
65
|
+
res_b = int_to_tc(res_i, width)
|
|
66
|
+
# charge for both operands' byte widths
|
|
67
|
+
if not meter.charge_bytes(len(a_b) + len(b_b)):
|
|
68
|
+
return error_expr("low_eval", "meter limit")
|
|
69
|
+
stack.append(res_b)
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# ---------- NAND ----------
|
|
73
|
+
if tok == b"nand":
|
|
74
|
+
if len(stack) < 2:
|
|
75
|
+
return error_expr("low_eval", "underflow")
|
|
76
|
+
b_b = stack.pop()
|
|
77
|
+
a_b = stack.pop()
|
|
78
|
+
res_b = nand_bytes(a_b, b_b)
|
|
79
|
+
# bitwise cost: 2 * max(len(a), len(b))
|
|
80
|
+
if not meter.charge_bytes(2 * max(len(a_b), len(b_b), 1)):
|
|
81
|
+
return error_expr("low_eval", "meter limit")
|
|
82
|
+
stack.append(res_b)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
# ---------- JUMP ----------
|
|
86
|
+
if tok == b"jump":
|
|
87
|
+
if len(stack) < 1:
|
|
88
|
+
return error_expr("low_eval", "underflow")
|
|
89
|
+
tgt_b = stack.pop()
|
|
90
|
+
if not meter.charge_bytes(1):
|
|
91
|
+
return error_expr("low_eval", "meter limit")
|
|
92
|
+
tgt_i = tc_to_int(tgt_b)
|
|
93
|
+
if tgt_i < 0 or tgt_i >= len(code):
|
|
94
|
+
return error_expr("low_eval", "bad jump")
|
|
95
|
+
pc = tgt_i
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# ---------- HEAP GET ----------
|
|
99
|
+
if tok == b"heap_get":
|
|
100
|
+
if len(stack) < 1:
|
|
101
|
+
return error_expr("low_eval", "underflow")
|
|
102
|
+
key = stack.pop()
|
|
103
|
+
val = heap.get(key) or b""
|
|
104
|
+
# get cost: 1
|
|
105
|
+
if not meter.charge_bytes(1):
|
|
106
|
+
return error_expr("low_eval", "meter limit")
|
|
107
|
+
stack.append(val)
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# ---------- HEAP SET ----------
|
|
111
|
+
if tok == b"heap_set":
|
|
112
|
+
if len(stack) < 2:
|
|
113
|
+
return error_expr("low_eval", "underflow")
|
|
114
|
+
val = stack.pop()
|
|
115
|
+
key = stack.pop()
|
|
116
|
+
if not meter.charge_bytes(len(val)):
|
|
117
|
+
return error_expr("low_eval", "meter limit")
|
|
118
|
+
heap[key] = val
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# if no opcode matched above, treat token as literal
|
|
122
|
+
# not an opcode → literal blob
|
|
123
|
+
stack.append(tok)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Meter:
|
|
5
|
+
def __init__(self, enabled: bool = True, limit: Optional[int] = None):
|
|
6
|
+
self.enabled = enabled
|
|
7
|
+
self.limit: Optional[int] = limit
|
|
8
|
+
self.used: int = 0
|
|
9
|
+
|
|
10
|
+
def charge_bytes(self, n: int) -> bool:
|
|
11
|
+
if not self.enabled:
|
|
12
|
+
return True
|
|
13
|
+
if n < 0:
|
|
14
|
+
n = 0
|
|
15
|
+
if self.limit is not None and (self.used + n) >= self.limit:
|
|
16
|
+
return False
|
|
17
|
+
self.used += n
|
|
18
|
+
return True
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import List, Tuple
|
|
2
|
+
from . import Expr
|
|
3
|
+
|
|
4
|
+
class ParseError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
def _parse_one(tokens: List[str], pos: int = 0) -> Tuple[Expr, int]:
|
|
8
|
+
if pos >= len(tokens):
|
|
9
|
+
raise ParseError("unexpected end")
|
|
10
|
+
tok = tokens[pos]
|
|
11
|
+
|
|
12
|
+
if tok == '(': # list
|
|
13
|
+
items: List[Expr] = []
|
|
14
|
+
i = pos + 1
|
|
15
|
+
while i < len(tokens):
|
|
16
|
+
if tokens[i] == ')':
|
|
17
|
+
return Expr.ListExpr(items), i + 1
|
|
18
|
+
expr, i = _parse_one(tokens, i)
|
|
19
|
+
items.append(expr)
|
|
20
|
+
raise ParseError("expected ')'")
|
|
21
|
+
|
|
22
|
+
if tok == ')':
|
|
23
|
+
raise ParseError("unexpected ')'")
|
|
24
|
+
|
|
25
|
+
# try integer → Bytes (variable-length two's complement)
|
|
26
|
+
try:
|
|
27
|
+
n = int(tok)
|
|
28
|
+
# encode as minimal-width signed two's complement, big-endian
|
|
29
|
+
def int_to_min_tc(v: int) -> bytes:
|
|
30
|
+
"""Return the minimal-width signed two's complement big-endian
|
|
31
|
+
byte encoding of integer v. Width expands just enough so that
|
|
32
|
+
decoding with signed=True yields the same value and sign.
|
|
33
|
+
Example: 0 -> b"\x00", 127 -> b"\x7f", 128 -> b"\x00\x80".
|
|
34
|
+
"""
|
|
35
|
+
if v == 0:
|
|
36
|
+
return b"\x00"
|
|
37
|
+
w = 1
|
|
38
|
+
while True:
|
|
39
|
+
try:
|
|
40
|
+
return v.to_bytes(w, "big", signed=True)
|
|
41
|
+
except OverflowError:
|
|
42
|
+
w += 1
|
|
43
|
+
|
|
44
|
+
return Expr.Bytes(int_to_min_tc(n)), pos + 1
|
|
45
|
+
except ValueError:
|
|
46
|
+
return Expr.Symbol(tok), pos + 1
|
|
47
|
+
|
|
48
|
+
def parse(tokens: List[str]) -> Tuple[Expr, List[str]]:
|
|
49
|
+
"""Parse tokens into an Expr and return (expr, remaining_tokens)."""
|
|
50
|
+
expr, next_pos = _parse_one(tokens, 0)
|
|
51
|
+
return expr, tokens[next_pos:]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def tokenize(source: str) -> List[str]:
|
|
5
|
+
tokens: List[str] = []
|
|
6
|
+
cur: List[str] = []
|
|
7
|
+
for ch in source:
|
|
8
|
+
if ch.isspace():
|
|
9
|
+
if cur:
|
|
10
|
+
tokens.append("".join(cur))
|
|
11
|
+
cur = []
|
|
12
|
+
continue
|
|
13
|
+
if ch in ("(", ")"):
|
|
14
|
+
if cur:
|
|
15
|
+
tokens.append("".join(cur))
|
|
16
|
+
cur = []
|
|
17
|
+
tokens.append(ch)
|
|
18
|
+
continue
|
|
19
|
+
cur.append(ch)
|
|
20
|
+
if cur:
|
|
21
|
+
tokens.append("".join(cur))
|
|
22
|
+
return tokens
|
astreum/_node.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
import uuid
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from astreum._storage.atom import AtomKind
|
|
8
|
+
|
|
9
|
+
from ._storage import Atom, storage_setup
|
|
10
|
+
from ._lispeum import Env, Expr, Meter, low_eval, parse, tokenize, ParseError
|
|
11
|
+
from .utils.logging import logging_setup
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Node",
|
|
15
|
+
"Env",
|
|
16
|
+
"Expr",
|
|
17
|
+
"Meter",
|
|
18
|
+
"parse",
|
|
19
|
+
"tokenize",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
def bytes_touched(*vals: bytes) -> int:
|
|
23
|
+
"""For metering: how many bytes were manipulated (max of operands)."""
|
|
24
|
+
return max((len(v) for v in vals), default=1)
|
|
25
|
+
|
|
26
|
+
class Node:
|
|
27
|
+
def __init__(self, config: dict):
|
|
28
|
+
self.logger = logging_setup(config)
|
|
29
|
+
self.logger.info("Starting Astreum Node")
|
|
30
|
+
# Storage Setup
|
|
31
|
+
storage_setup(self, config=config)
|
|
32
|
+
# Lispeum Setup
|
|
33
|
+
self.environments: Dict[uuid.UUID, Env] = {}
|
|
34
|
+
self.machine_environments_lock = threading.RLock()
|
|
35
|
+
self.low_eval = low_eval
|
|
36
|
+
# Communication and Validation Setup (import lazily to avoid heavy deps during parsing tests)
|
|
37
|
+
try:
|
|
38
|
+
from astreum._communication import communication_setup # type: ignore
|
|
39
|
+
communication_setup(node=self, config=config)
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
try:
|
|
43
|
+
from astreum._consensus import consensus_setup # type: ignore
|
|
44
|
+
consensus_setup(node=self, config=config)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---- Env helpers ----
|
|
51
|
+
def env_get(self, env_id: uuid.UUID, key: bytes) -> Optional[Expr]:
|
|
52
|
+
cur = self.environments.get(env_id)
|
|
53
|
+
while cur is not None:
|
|
54
|
+
if key in cur.data:
|
|
55
|
+
return cur.data[key]
|
|
56
|
+
cur = self.environments.get(cur.parent_id) if cur.parent_id else None
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
def env_set(self, env_id: uuid.UUID, key: bytes, value: Expr) -> bool:
|
|
60
|
+
with self.machine_environments_lock:
|
|
61
|
+
env = self.environments.get(env_id)
|
|
62
|
+
if env is None:
|
|
63
|
+
return False
|
|
64
|
+
env.data[key] = value
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
# Storage
|
|
68
|
+
def _hot_storage_get(self, key: bytes) -> Optional[Atom]:
|
|
69
|
+
atom = self.hot_storage.get(key)
|
|
70
|
+
if atom is not None:
|
|
71
|
+
self.hot_storage_hits[key] = self.hot_storage_hits.get(key, 0) + 1
|
|
72
|
+
return atom
|
|
73
|
+
|
|
74
|
+
def _hot_storage_set(self, key: bytes, value: Atom) -> bool:
|
|
75
|
+
"""Store atom in hot storage without exceeding the configured limit."""
|
|
76
|
+
projected = self.hot_storage_size + value.size
|
|
77
|
+
if projected > self.hot_storage_limit:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
self.hot_storage[key] = value
|
|
81
|
+
self.hot_storage_size = projected
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
def _network_get(self, key: bytes) -> Optional[Atom]:
|
|
85
|
+
# locate storage provider
|
|
86
|
+
# query storage provider
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def storage_get(self, key: bytes) -> Optional[Atom]:
|
|
90
|
+
"""Retrieve an Atom by checking local storage first, then the network."""
|
|
91
|
+
atom = self._hot_storage_get(key)
|
|
92
|
+
if atom is not None:
|
|
93
|
+
return atom
|
|
94
|
+
atom = self._cold_storage_get(key)
|
|
95
|
+
if atom is not None:
|
|
96
|
+
return atom
|
|
97
|
+
return self._network_get(key)
|
|
98
|
+
|
|
99
|
+
def _cold_storage_get(self, key: bytes) -> Optional[Atom]:
|
|
100
|
+
"""Read an atom from the cold storage directory if configured."""
|
|
101
|
+
if not self.cold_storage_path:
|
|
102
|
+
return None
|
|
103
|
+
filename = f"{key.hex().upper()}.bin"
|
|
104
|
+
file_path = Path(self.cold_storage_path) / filename
|
|
105
|
+
try:
|
|
106
|
+
data = file_path.read_bytes()
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
return None
|
|
109
|
+
except OSError:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
return Atom.from_bytes(data)
|
|
113
|
+
except ValueError:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def _cold_storage_set(self, atom: Atom) -> None:
|
|
117
|
+
"""Persist an atom into the cold storage directory if it already exists."""
|
|
118
|
+
if not self.cold_storage_path:
|
|
119
|
+
return
|
|
120
|
+
atom_bytes = atom.to_bytes()
|
|
121
|
+
projected = self.cold_storage_size + len(atom_bytes)
|
|
122
|
+
if self.cold_storage_limit and projected > self.cold_storage_limit:
|
|
123
|
+
return
|
|
124
|
+
directory = Path(self.cold_storage_path)
|
|
125
|
+
if not directory.exists():
|
|
126
|
+
return
|
|
127
|
+
atom_id = atom.object_id()
|
|
128
|
+
filename = f"{atom_id.hex().upper()}.bin"
|
|
129
|
+
file_path = directory / filename
|
|
130
|
+
try:
|
|
131
|
+
file_path.write_bytes(atom_bytes)
|
|
132
|
+
self.cold_storage_size = projected
|
|
133
|
+
except OSError:
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
def _network_set(self, atom: Atom) -> None:
|
|
137
|
+
"""Advertise an atom to the closest known peer so they can fetch it from us."""
|
|
138
|
+
try:
|
|
139
|
+
from ._communication.message import Message, MessageTopic
|
|
140
|
+
except Exception:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
atom_id = atom.object_id()
|
|
144
|
+
try:
|
|
145
|
+
closest_peer = self.peer_route.closest_peer_for_hash(atom_id)
|
|
146
|
+
except Exception:
|
|
147
|
+
return
|
|
148
|
+
if closest_peer is None or closest_peer.address is None:
|
|
149
|
+
return
|
|
150
|
+
target_addr = closest_peer.address
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
provider_ip, provider_port = self.incoming_socket.getsockname()[:2]
|
|
154
|
+
except Exception:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
provider_str = f"{provider_ip}:{int(provider_port)}"
|
|
158
|
+
try:
|
|
159
|
+
provider_bytes = provider_str.encode("utf-8")
|
|
160
|
+
except Exception:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
payload = atom_id + provider_bytes
|
|
164
|
+
message = Message(topic=MessageTopic.STORAGE_REQUEST, content=payload)
|
|
165
|
+
self.outgoing_queue.put((message.to_bytes(), target_addr))
|
|
166
|
+
|
|
167
|
+
def get_expr_list_from_storage(self, key: bytes) -> Optional["ListExpr"]:
|
|
168
|
+
atoms = self.get_atom_list_from_storage(root_hash=key)
|
|
169
|
+
if atoms is None:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
expr_list = []
|
|
173
|
+
for atom in atoms:
|
|
174
|
+
match atom.kind:
|
|
175
|
+
case AtomKind.SYMBOL:
|
|
176
|
+
expr_list.append(Expr.Symbol(atom.data))
|
|
177
|
+
case AtomKind.BYTES:
|
|
178
|
+
expr_list.append(Expr.Bytes(atom.data))
|
|
179
|
+
case AtomKind.LIST:
|
|
180
|
+
expr_list.append(Expr.ListExpr([
|
|
181
|
+
Expr.Bytes(atom.data),
|
|
182
|
+
Expr.Symbol("ref")
|
|
183
|
+
]))
|
|
184
|
+
|
|
185
|
+
expr_list.reverse()
|
|
186
|
+
return Expr.ListExpr(expr_list)
|
|
187
|
+
|
|
188
|
+
def get_atom_list_from_storage(self, root_hash: bytes) -> Optional[List["Atom"]]:
|
|
189
|
+
next_id: Optional[bytes] = root_hash
|
|
190
|
+
atom_list: List["Atom"] = []
|
|
191
|
+
while next_id:
|
|
192
|
+
elem = self.storage_get(key=next_id)
|
|
193
|
+
if elem:
|
|
194
|
+
atom_list.append(elem)
|
|
195
|
+
next_id = elem.next
|
|
196
|
+
else:
|
|
197
|
+
return None
|
|
198
|
+
return atom_list
|
astreum/_storage/atom.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
from typing import List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from blake3 import blake3
|
|
7
|
+
|
|
8
|
+
ZERO32 = b"\x00"*32
|
|
9
|
+
|
|
10
|
+
def u64_le(n: int) -> bytes:
|
|
11
|
+
return int(n).to_bytes(8, "little", signed=False)
|
|
12
|
+
|
|
13
|
+
def hash_bytes(b: bytes) -> bytes:
|
|
14
|
+
return blake3(b).digest()
|
|
15
|
+
|
|
16
|
+
class AtomKind(IntEnum):
|
|
17
|
+
SYMBOL = 0
|
|
18
|
+
BYTES = 1
|
|
19
|
+
LIST = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Atom:
|
|
23
|
+
data: bytes
|
|
24
|
+
next: bytes
|
|
25
|
+
size: int
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
data: bytes,
|
|
30
|
+
next: bytes = ZERO32,
|
|
31
|
+
size: Optional[int] = None,
|
|
32
|
+
kind: AtomKind = AtomKind.BYTES,
|
|
33
|
+
):
|
|
34
|
+
self.data = data
|
|
35
|
+
self.next = next
|
|
36
|
+
self.size = len(data) if size is None else size
|
|
37
|
+
self.kind = kind
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def from_data(
|
|
41
|
+
data: bytes,
|
|
42
|
+
next_hash: bytes = ZERO32,
|
|
43
|
+
kind: AtomKind = AtomKind.BYTES,
|
|
44
|
+
) -> "Atom":
|
|
45
|
+
return Atom(data=data, next=next_hash, size=len(data), kind=kind)
|
|
46
|
+
|
|
47
|
+
def generate_id(self) -> bytes:
|
|
48
|
+
"""Compute the object id using this atom's metadata."""
|
|
49
|
+
kind_bytes = int(self.kind).to_bytes(1, "little", signed=False)
|
|
50
|
+
return blake3(
|
|
51
|
+
kind_bytes + self.data_hash() + self.next + u64_le(self.size)
|
|
52
|
+
).digest()
|
|
53
|
+
|
|
54
|
+
def data_hash(self) -> bytes:
|
|
55
|
+
return hash_bytes(self.data)
|
|
56
|
+
|
|
57
|
+
def object_id(self) -> bytes:
|
|
58
|
+
return self.generate_id()
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def verify_metadata(
|
|
62
|
+
object_id: bytes,
|
|
63
|
+
size: int,
|
|
64
|
+
next_hash: bytes,
|
|
65
|
+
data_hash: bytes,
|
|
66
|
+
kind: AtomKind,
|
|
67
|
+
) -> bool:
|
|
68
|
+
kind_bytes = int(kind).to_bytes(1, "little", signed=False)
|
|
69
|
+
expected = blake3(kind_bytes + data_hash + next_hash + u64_le(size)).digest()
|
|
70
|
+
return object_id == expected
|
|
71
|
+
|
|
72
|
+
def to_bytes(self) -> bytes:
|
|
73
|
+
"""Serialize as next-hash + kind byte + payload."""
|
|
74
|
+
kind_byte = int(self.kind).to_bytes(1, "little", signed=False)
|
|
75
|
+
return self.next + kind_byte + self.data
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def from_bytes(buf: bytes) -> "Atom":
|
|
79
|
+
header_len = len(ZERO32)
|
|
80
|
+
if len(buf) < header_len + 1:
|
|
81
|
+
raise ValueError("buffer too short for Atom header")
|
|
82
|
+
next_hash = buf[:header_len]
|
|
83
|
+
kind_value = buf[header_len]
|
|
84
|
+
data = buf[header_len + 1 :]
|
|
85
|
+
try:
|
|
86
|
+
kind = AtomKind(kind_value)
|
|
87
|
+
except ValueError as exc:
|
|
88
|
+
raise ValueError(f"unknown atom kind: {kind_value}") from exc
|
|
89
|
+
return Atom(data=data, next=next_hash, size=len(data), kind=kind)
|
|
90
|
+
|
|
91
|
+
def bytes_list_to_atoms(values: List[bytes]) -> Tuple[bytes, List[Atom]]:
|
|
92
|
+
"""Build a forward-ordered linked list of atoms from byte payloads.
|
|
93
|
+
|
|
94
|
+
Returns the head object's hash (ZERO32 if no values) and the atoms created.
|
|
95
|
+
"""
|
|
96
|
+
next_hash = ZERO32
|
|
97
|
+
atoms: List[Atom] = []
|
|
98
|
+
|
|
99
|
+
for value in reversed(values):
|
|
100
|
+
atom = Atom.from_data(
|
|
101
|
+
data=bytes(value),
|
|
102
|
+
next_hash=next_hash,
|
|
103
|
+
kind=AtomKind.BYTES,
|
|
104
|
+
)
|
|
105
|
+
atoms.append(atom)
|
|
106
|
+
next_hash = atom.object_id()
|
|
107
|
+
|
|
108
|
+
atoms.reverse()
|
|
109
|
+
return (next_hash if values else ZERO32), atoms
|