commit aa838eedc4a4fb6ff5e01e853efbddd0fbc95518
parent 055d703a1c30762d046570766b00508fe82558c0
Author: lash <dev@holbrook.no>
Date: Fri, 13 Feb 2026 18:01:54 +0000
Add store support for entry attachments
Diffstat:
5 files changed, 198 insertions(+), 55 deletions(-)
diff --git a/dummy/tests/store.py b/dummy/tests/store.py
@@ -8,7 +8,7 @@ import uuid
import lxml.etree
from whee.mem import MemStore
-from usawa import Ledger, UnitIndex, EntryPart, Entry, DemoWallet
+from usawa import Ledger, UnitIndex, EntryPart, Entry, DemoWallet, Asset
from usawa.store import LedgerStore
from usawa.crypto import ACL
@@ -40,16 +40,42 @@ class TestStore(unittest.TestCase):
wallet = DemoWallet()
o.sign(wallet)
store.add_entry(o)
- r = store.get_entry(o.serial)
+
+ acl = ACL.from_wallet(wallet)
+ r = store.get_entry(o.serial, acl=acl)
+ self.assertEqual(r.ref, o.ref)
+ self.assertEqual(r.description, o.description)
+
+
+ def test_store_entry_attach(self):
+ uidx = UnitIndex('FOO')
+ ledger = Ledger(uidx, serial=42, base=self.parent)
+ store = LedgerStore(self.store, ledger)
+ dst = EntryPart('FOO', 'asset', 'foo', 1337)
+ src = EntryPart('FOO', 'income', 'foo', 1337, debit=True)
+ o = Entry(42, datetime.datetime.strptime('2025-11-11', '%Y-%m-%d'), parent=self.parent, ref=self.ref, description=self.description, tx_datereg=self.dtreg, unitindex=uidx)
+ o.add_part(src, debit=True)
+ o.add_part(dst)
+
+ fp = os.path.join(testdir, 'test.xml')
+ asset = Asset.from_file(fp, description='foobar')
+ store.add_asset(asset)
+ o.attach(asset)
+
+ wallet = DemoWallet()
+ o.sign(wallet)
+ store.add_entry(o)
+
+ acl = ACL.from_wallet(wallet)
+ r = store.get_entry(o.serial, acl=acl)
self.assertEqual(r.ref, o.ref)
self.assertEqual(r.description, o.description)
+ self.assertEqual(r.attachment[0].description, 'foobar')
def test_store_ledger(self):
uidx = UnitIndex('FOO')
wallet = DemoWallet()
- #acl = ACL()
- #acl.add(wallet.pubkey())
acl = ACL.from_wallet(wallet)
ledger = Ledger(uidx, serial=41, base=self.parent, acl=acl, wallet=wallet)
store = LedgerStore(self.store, ledger)
@@ -75,8 +101,7 @@ class TestStore(unittest.TestCase):
store.add_entry(o)
ledger.sign()
- #ledger.reset()
- store.load()
+ store.load(acl=acl)
if __name__ == '__main__':
diff --git a/dummy/usawa/asset.py b/dummy/usawa/asset.py
@@ -5,6 +5,7 @@ import os
import uuid
import lxml.etree
+import rencode
import magic
from .constant import NSPREFIX
@@ -49,8 +50,8 @@ class Asset:
Asset.from_io() - Read from a io.BufferedIOBase input stream.
Asset.from_tree() - Recreate from an XML tree.
"""
- def __init__(self):
- self.digest = None
+ def __init__(self, digest=None):
+ self.digest = digest
self.mime = None
self.enc = None
self.slug = None
@@ -91,6 +92,20 @@ class Asset:
return s
+ """Return the digest of the asset, in hex.
+
+ :raises AttributeError: Digest not set
+ :return: Digest hex
+ :rtype: str
+ """
+ def get_digest(self, binary=False):
+ if self.digest == None:
+ raise AttributeError('')
+ if binary:
+ return self.digest
+ return self.digest.hex()
+
+
"""Instantiate an asset object from a local file.
File is opened and the file object is passed on to the from_io() method.
@@ -270,5 +285,54 @@ class Asset:
return o
+ """Generate the simple data structure used for rencode serialization.
+
+ :returns: data structure
+ :rtype: list
+ """
+ def to_list(self):
+ d = [
+ self.mime,
+ self.uuid,
+ self.extref,
+ self.slug,
+ self.ext,
+ self.description,
+ ]
+ return d
+
+
+ """Generate the serialization format used to calculate the digest for the asset.
+
+ :returns: String representation of the entry, in rencode format.
+ :rtype: str
+ """
+ def serialize(self):
+ b = self.to_list()
+ return rencode.dumps(b)
+
+
+ """Create an entry object from serialized data.
+
+ :param data: rencoded entry object, as produced by the serialize() method.
+ :type data: str
+ :returns: Entry object.
+ :rtype: usawa.Entry
+ """
+ @staticmethod
+ def deserialize(data, digest):
+ o = Asset()
+ v = rencode.loads(data)
+ i = 0
+ for k in ['mime', 'uuid', 'extref', 'slug', 'ext', 'description']:
+ if v[i] != None:
+ setattr(o, k, v[i].decode('utf-8'))
+ i += 1
+ if isinstance(digest, str):
+ digest = bytes.fromhex(digest)
+ o.digest = digest
+ return o
+
+
def __str__(self):
return 'file ̈́' + self.get_filename() + ' mime ' + self.get_mimestring() + ' digest ' + self.digest.hex()
diff --git a/dummy/usawa/crypto.py b/dummy/usawa/crypto.py
@@ -143,7 +143,7 @@ class Wallet:
:rtype: boolean
"""
def verify(self, v, sig):
- raise NotImplementedError
+ raise NotImplementedError
"""Generate an identity XML tree entry from the wallet.
@@ -285,8 +285,8 @@ class ACL:
:type did: usawa.DID
"""
def add(self, who, what=None, label=None, did=DEFAULT_DID):
- if isinstance(who, bytes):
- who = who.hex()
+ if isinstance(who, str):
+ who = bytes.fromhex(who)
if label == None:
label = who
if what == None:
@@ -304,8 +304,8 @@ class ACL:
:rtype: boolean
"""
def have(self, who):
- if isinstance(who, bytes):
- who = who.hex()
+ if isinstance(who, str):
+ who = bytes.fromhex(who)
return self.rev[who]
diff --git a/dummy/usawa/entry.py b/dummy/usawa/entry.py
@@ -190,7 +190,7 @@ class Entry:
:type asset: usawa.Asset
"""
def attach(self, asset):
- logg.debug('attach {} to {}'.format(asset, self))
+ logg.debug('attach {} to {}'.format(asset.get_digest(), self))
self.attachment.append(asset)
@@ -253,6 +253,7 @@ class Entry:
return entry
+
"""Generate the simple data structure used for rencode serialization.
:returns: data structure
@@ -261,12 +262,16 @@ class Entry:
def to_list(self):
debit = []
credit = []
+ attach = []
for v in self.debit:
debit.append((v.unit, v.typ, v.account, v.amount,))
for v in self.credit:
credit.append((v.unit, v.typ, v.account, v.amount,))
+ for v in self.attachment:
+ attach.append(v.get_digest(binary=True))
+
d = [
self.parent,
self.serial,
@@ -276,6 +281,7 @@ class Entry:
self.description,
debit,
credit,
+ attach,
]
return d
@@ -309,6 +315,7 @@ class Entry:
description = v[5].decode('utf-8')
src_data = v[6]
dst_data = v[7]
+ attach_data = v[8]
o = Entry(serial, date, ref=ref, description=description, parent=parent, tx_datereg=date_reg)
for v in src_data:
src = EntryPart(v[0].decode('utf-8'), v[1].decode('utf-8'), v[2].decode('utf-8'), v[3], debit=True)
@@ -317,8 +324,13 @@ class Entry:
for v in dst_data:
dst = EntryPart(v[0].decode('utf-8'), v[1].decode('utf-8'), v[2].decode('utf-8'), v[3])
o.add_part(dst)
- logg.debug('deserialized entry {}'.format(o))
+
+ for v in attach_data:
+ asset = Asset(digest=v)
+ o.attach(asset)
+ logg.debug('deserialized entry {}'.format(o))
+
return o
@@ -408,30 +420,47 @@ class Entry:
@staticmethod
def unwrap(data, acl=None):
v = rencode.loads(data)
+ entry = Entry.deserialize(v[2])
pubkey_bytes = v[0][0][1]
- if acl != None:
- label = None
- try:
- label = acl.have(pubkey_bytes)
- except KeyError:
- raise ACLError()
- if not acl.may(label, 0x01):
- raise ACLError()
- wallet = DemoWallet(publickey=pubkey_bytes)
sig = v[1][0]
- entry = Entry.deserialize(v[2])
entry.add_signature(pubkey_bytes, sig)
- entry.verify(wallet)
+
+ if acl == None:
+ return entry
+
+ label = None
+ try:
+ label = acl.have(pubkey_bytes)
+ except KeyError:
+ raise ACLError()
+ if not acl.may(label, 0x01):
+ raise ACLError()
+ wallet = DemoWallet(publickey=pubkey_bytes)
+ entry.verify(wallet, acl=acl)
+
return entry
- def verify(self, wallet):
+ def verify(self, wallet=None, acl=None):
+ if wallet == None and acl == None:
+ raise ValueError('verify needs at least one of wallet or acl')
(z, b) = self.sum()
pubkeys = list(self.sigs.keys())
sig = self.sigs[pubkeys[0]]
- if not wallet.verify(z, sig):
+ if wallet != None:
+ if wallet.verify(z, sig):
+ return
+ have = False
+ if acl != None:
+ for pubkey in acl.pubkeys(binary=True):
+ logg.debug('publickey acl {}'.format(pubkey))
+ wallet = DemoWallet(publickey=pubkey)
+ sig = self.sigs[pubkey.hex()]
+ if wallet.verify(z, sig):
+ have = True
+ break
+ if not have:
raise VerifyError()
- # TODO: demo only takes into account single signature
"""Generate and return an XML representation of the entry.
diff --git a/dummy/usawa/store.py b/dummy/usawa/store.py
@@ -6,12 +6,14 @@ from whee import Interface
from .ledger import Ledger
from .entry import Entry
+from .asset import Asset
PFX_KEY = b'\x00'
PFX_LEDGER = b'\x01'
PFX_LEDGER_LOCK = b'\x02'
PFX_ENTRY = b'\x04'
+PFX_ASSET = b'\x10'
logg = logging.getLogger('usawa.store')
@@ -40,13 +42,6 @@ def pfx_key(pubkey=None):
:rtype: bytes
"""
def pfx_ledger_topic(topic):
- """Return ledger store prefix for topic.
-
- :params topic: Topic to generate prefix for.
- :type topic: bytes
- :returns: Prefix.
- :rtype: bytes
- """
r = PFX_LEDGER + topic
return r
@@ -59,13 +54,6 @@ def pfx_ledger_topic(topic):
:rtype: bytes
"""
def pfx_ledger_lock(topic):
- """Return ledger store locking prefix for topic.
-
- :params topic: Topic to generate prefix for.
- :type topic: bytes
- :returns: Prefix.
- :rtype: bytes
- """
r = PFX_LEDGER_LOCK + topic
return r
@@ -80,15 +68,6 @@ def pfx_ledger_lock(topic):
:rtype: bytes
"""
def pfx_entry(ledger, entry):
- """Return ledger store prefix for an entry.
-
- :param ledger: Ledger context for the entry.
- :type ledger: usawa.Ledger
- :param entry: Entry or serial to create prefix for.
- :type entry: usawa.Entry or int
- :returns: Prefix.
- :rtype: bytes
- """
serial = 0
if isinstance(entry, Entry):
serial = entry.serial
@@ -101,6 +80,16 @@ def pfx_entry(ledger, entry):
return PFX_LEDGER + ledger.topic + serial.to_bytes(8, byteorder='big')
+"""DB key prefix for adding entry attachment asset to a ledger.
+
+"""
+def pfx_asset(asset):
+ if not isinstance(asset, Asset):
+ raise ValueError('invalid asset')
+ return PFX_ASSET + asset.get_digest(binary=True)
+
+
+
class LedgerStore(Interface):
"""Wrapper for an implementation of the whee store that handles encoding of ledgers and entries.
@@ -181,11 +170,43 @@ class LedgerStore(Interface):
:raises: PermissionError if the entry does not have a valid signature.
:raises: ValueError if the serial number cannot be retrieved from the entry argument.
:raises: FileExistsError if entry is already in store.
+ :todo: optimize replacing asset stub with deserialized asset
"""
def get_entry(self, entry, acl=None):
k = pfx_entry(self.ledger, entry)
v = self.__o.get(k)
- return Entry.unwrap(v, acl=acl)
+ entry = Entry.unwrap(v)
+ # TODO: hacky!
+ i = 0
+ for o in entry.attachment:
+ logg.debug('getentry ' + o.get_digest())
+ asset = self.get_asset(o)
+ #asset = Asset.deserialize(v, digest=o.get_digest(binary=True))
+ entry.attachment[i] = asset
+ i += 1
+ entry.verify(acl=acl)
+ return entry
+
+
+ """Add an entry attachment asset to the store.
+
+ :param asset: Asset containing digest to restore.
+ :type asset: usawa.Asset
+ :raises: FileExistsError if entry is already in store.
+ """
+ def add_asset(self, asset):
+ k = pfx_asset(asset)
+ v = asset.serialize()
+ self.__o.put(k, v)
+
+
+ """Restore an entry attachment asset from the store.
+ """
+ def get_asset(self, asset):
+ k = pfx_asset(asset)
+ v = self.__o.get(k)
+ digest = asset.get_digest(binary=True)
+ return Asset.deserialize(v, digest)
"""Flush ledger and load all entries from store.
@@ -196,12 +217,12 @@ class LedgerStore(Interface):
:raises FileNotFoundError: If an entry cannot be found.
"""
- def load(self):
+ def load(self, acl=None):
logg.debug('load ledger from store {}'.format(self.ledger))
while True:
o = None
try:
- o = self.get_entry(self.ledger.next_serial())
+ o = self.get_entry(self.ledger.next_serial(), acl=acl)
except FileNotFoundError:
break
self.ledger.add_entry(o)
@@ -247,9 +268,13 @@ class LedgerStore(Interface):
return self.__o.get(k)
+ """Implements whee.Interface.put
+ """
def put(self, k, v):
return self.__o.put(k, v)
+ """Implements whee.Interface.get
+ """
def get(self, k):
return self.__o.get(k)