MetaData Sharing
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

2906 lines
77 KiB

  1. #!/usr/bin/env python
  2. import collections
  3. import stat
  4. import sys
  5. import logging
  6. # useful for debugging when stderr is redirected/captured
  7. _real_stderr = sys.stderr
  8. _sql_verbose = False
  9. if False:
  10. lvl = logging.DEBUG
  11. lvl = logging.INFO
  12. _handler = logging.StreamHandler(_real_stderr)
  13. _handler.setLevel(lvl)
  14. _handler.setFormatter(logging.Formatter('%(asctime)s: %(message)s'))
  15. import sqlalchemy
  16. logging.getLogger('sqlalchemy').addHandler(_handler)
  17. logging.getLogger('sqlalchemy.engine').setLevel(lvl)
  18. from .utils import _debprint, enable_debug, disable_debug
  19. def _getquery(q, objstr):
  20. return repr(str(q.compile(objstr._engine,
  21. compile_kwargs={"literal_binds": True})).replace('\n', ' '))
  22. #import pdb, sys; mypdb = pdb.Pdb(stdout=sys.stderr); mypdb.set_trace()
  23. from edgold.ed448 import EDDSA448
  24. from unittest import mock
  25. from .hostid import hostuuid
  26. from .tags import TagCache
  27. from . import orm
  28. from .magic import detect_from_filename
  29. from .btv import _TestCases as bttestcase, validate_file
  30. import base64
  31. import base58
  32. from .btv import bencode
  33. import copy
  34. import datetime
  35. import functools
  36. import hashlib
  37. import importlib
  38. import io
  39. import itertools
  40. import json
  41. import libarchive
  42. import magic
  43. import operator
  44. import os.path
  45. import pathlib
  46. import pasn1
  47. import re
  48. import shutil
  49. import socket
  50. import sqlalchemy
  51. from sqlalchemy import create_engine, select, insert, func, delete, text
  52. from sqlalchemy.orm import sessionmaker, aliased, load_only
  53. import string
  54. import subprocess
  55. import sys
  56. import tempfile
  57. import unittest
  58. import uuid
  59. # The UUID for the namespace representing the path to a file
  60. _NAMESPACE_MEDASHARE_PATH = uuid.UUID('f6f36b62-3770-4a68-bc3d-dc3e31e429e6')
  61. _NAMESPACE_MEDASHARE_CONTAINER = uuid.UUID('890a9d5c-0626-4de1-ab05-9e14947391eb')
  62. _defaulthash = 'sha512'
  63. _validhashes = set([ 'sha256', 'sha512' ])
  64. _hashlengths = { len(getattr(hashlib, x)().hexdigest()): x for x in
  65. _validhashes }
  66. def _makehashuri(hashstr):
  67. hash, value = ObjectStore.makehash(hashstr).split(':')
  68. return f'hash://{hash}/{value}'
  69. def _keyordering(x):
  70. k, v = x
  71. try:
  72. return (MDBase._common_names_list.index(k), k, v)
  73. except ValueError:
  74. return (2**32, k, v)
  75. def _iterdictlist(obj, **kwargs):
  76. l = list(sorted(obj.items(**kwargs), key=_keyordering))
  77. for k, v in l:
  78. if isinstance(v, list):
  79. for i in sorted(v):
  80. yield k, i
  81. else:
  82. yield k, v
  83. _metaopre = re.compile('meta:(?P<key>[a-zA-Z]+)(?P<op>=|<|>|!=|<=|>=)(?P<val>.*)')
  84. _metaoplookup = {
  85. '=': operator.eq, '!=': operator.ne,
  86. '<': operator.lt, '>': operator.gt,
  87. '<=': operator.le, '>=': operator.ge
  88. }
  89. _metavalueconv = {
  90. 'modified': datetime.datetime.fromisoformat,
  91. }
  92. from .utils import _makeuuid, _makedatetime, _asn1coder
  93. from .mdb import MDBase
  94. class MetaData(MDBase):
  95. _type = 'metadata'
  96. _uniq_properties = set([ 'ms:tag' ])
  97. class Identity(MDBase):
  98. _type = 'identity'
  99. # Identites don't need a created by
  100. _common_properties = [ x for x in MDBase._common_properties if x !=
  101. 'created_by_ref' ]
  102. _common_optional = set([ x for x in MDBase._common_optional if x !=
  103. 'parent_refs' ] + [ 'name', 'pubkey' ])
  104. _common_names = set(_common_properties + list(
  105. MDBase._generated_properties.keys()))
  106. class Persona(object):
  107. '''The object that represents a persona, or identity. It will
  108. create the proper identity object, serialize for saving keys,
  109. create objects for that persona and other management.'''
  110. def __init__(self, identity=None, key=None):
  111. if identity is None:
  112. self._identity = Identity()
  113. else:
  114. self._identity = identity
  115. self._key = key
  116. self._pubkey = None
  117. if 'pubkey' in self._identity:
  118. pubkeybytes = self._identity.pubkey
  119. self._pubkey = EDDSA448(pub=pubkeybytes)
  120. self._created_by_ref = self._identity.uuid
  121. def Host(self, *args, **kwargs):
  122. kwargs['created_by_ref'] = self.uuid
  123. return self.sign(Host(*args, **kwargs))
  124. def Mapping(self, *args, **kwargs):
  125. kwargs['created_by_ref'] = self.uuid
  126. return self.sign(Mapping(*args, **kwargs))
  127. def Container(self, *args, **kwargs):
  128. kwargs['created_by_ref'] = self.uuid
  129. return self.sign(Container(*args, **kwargs))
  130. def MetaData(self, *args, **kwargs):
  131. kwargs['created_by_ref'] = self.uuid
  132. return self.sign(MetaData(*args, **kwargs))
  133. @property
  134. def uuid(self):
  135. '''Return the UUID of the identity represented.'''
  136. return self._identity.uuid
  137. def __repr__(self): # pragma: no cover
  138. r = '<Persona: has key: %s, has pubkey: %s, identity: %s>' % \
  139. (self._key is not None, self._pubkey is not None,
  140. repr(self._identity))
  141. return r
  142. @classmethod
  143. def from_pubkey(cls, pubkeystr):
  144. pubstr = base58.b58decode_check(pubkeystr)
  145. uuid, pubkey = _asn1coder.loads(pubstr)
  146. ident = Identity(uuid=uuid, pubkey=pubkey)
  147. return cls(ident)
  148. def get_identity(self):
  149. '''Return the Identity object for this Persona.'''
  150. return self._identity
  151. def get_pubkey(self):
  152. '''Get a printable version of the public key. This is used
  153. for importing into different programs, or for shared.'''
  154. idobj = self._identity
  155. pubstr = _asn1coder.dumps([ idobj.uuid, idobj.pubkey ])
  156. return base58.b58encode_check(pubstr).decode('ascii')
  157. def new_version(self, *args):
  158. '''Update the Persona's Identity object.'''
  159. self._identity = self.sign(self._identity.new_version(*args))
  160. return self._identity
  161. def store(self, fname):
  162. '''Store the Persona to a file. If there is a private
  163. key associated w/ the Persona, it will be saved as well.'''
  164. with open(fname, 'wb') as fp:
  165. obj = {
  166. 'identity': self._identity,
  167. }
  168. if self._key is not None:
  169. obj['key'] = \
  170. self._key.export_key('raw')
  171. fp.write(_asn1coder.dumps(obj))
  172. @classmethod
  173. def load(cls, fname):
  174. '''Load the Persona from the provided file.'''
  175. with open(fname, 'rb') as fp:
  176. objs = _asn1coder.loads(fp.read())
  177. kwargs = {}
  178. if 'key' in objs:
  179. kwargs['key'] = EDDSA448(objs['key'])
  180. return cls(Identity(objs['identity']), **kwargs)
  181. def generate_key(self):
  182. '''Generate a key for this Identity.
  183. Raises a RuntimeError if a key is already present.'''
  184. if self._key:
  185. raise RuntimeError('a key already exists')
  186. self._key = EDDSA448.generate()
  187. self._pubkey = self._key.public_key()
  188. pubkey = self._pubkey.export_key('raw')
  189. self._identity = self.sign(self._identity.new_version(('pubkey',
  190. pubkey)))
  191. def _makesigbytes(self, obj):
  192. obj = dict(obj.items(False))
  193. try:
  194. del obj['sig']
  195. except KeyError:
  196. pass
  197. return _asn1coder.dumps(obj)
  198. def sign(self, obj):
  199. '''Takes the object, adds a signature, and returns the new
  200. object.'''
  201. sigbytes = self._makesigbytes(obj)
  202. sig = self._key.sign(sigbytes)
  203. newobj = MDBase.create_obj(obj)
  204. newobj.sig = sig
  205. return newobj
  206. def verify(self, obj):
  207. sigbytes = self._makesigbytes(obj)
  208. pubkey = self._pubkey.export_key('raw')
  209. self._pubkey.verify(obj['sig'], sigbytes)
  210. return True
  211. def by_file(self, fname):
  212. '''Return a file object for the file named fname.'''
  213. fobj = FileObject.from_file(fname, self._created_by_ref)
  214. return self.sign(fobj)
  215. class ObjectStore(object):
  216. '''A container to store the various MetaData objects.'''
  217. # The _uuids property contains both the UUIDv4 for objects, and
  218. # looking up the UUIDv5 for FileObjects.
  219. def __init__(self, engine, version='head'):
  220. # Uncomment when working on the db schema
  221. #orm.Base.metadata.create_all(engine)
  222. self._engine = engine
  223. self._ses = sessionmaker(engine)
  224. self._handle_migration(version)
  225. def _handle_migration(self, version):
  226. '''Handle migrating the database to a newer version.'''
  227. # running commands directly:
  228. # pydoc3 alembic.config.Config
  229. # pydoc3 alembic.commands
  230. # inspecting the scripts directly:
  231. # alembic/script/base.py:61
  232. from alembic import command
  233. from alembic.config import Config
  234. config = Config()
  235. config.set_main_option("script_location", "medashare:alembic")
  236. with self._engine.begin() as connection:
  237. config.attributes['engine'] = self._engine
  238. command.upgrade(config, version)
  239. def get_host(self, hostuuid):
  240. hostuuid = _makeuuid(hostuuid)
  241. with self._ses() as session:
  242. a = session.get(orm.HostTable, hostuuid)
  243. if a is None:
  244. raise KeyError(hostuuid)
  245. return self._by_id(a.objid, session)
  246. def get_by_type(self, _type):
  247. try:
  248. if issubclass(_type, MDBase):
  249. _type = _type._type
  250. except TypeError:
  251. pass
  252. with self._ses() as session:
  253. for i in session.query(orm.MetaDataObject.data).where(
  254. orm.MetaDataObject.type == _type):
  255. yield i.data
  256. def get_hosts(self):
  257. return self.get_by_type(Host)
  258. @staticmethod
  259. def makehash(hashstr, strict=True):
  260. '''Take a hash or hash string, and return a valid hash
  261. string from it.
  262. This makes sure that it is of the correct type and length.
  263. If strict is False, the function will detect the length and
  264. return a valid hash string if one can be found.
  265. By default, the string must be prepended by the type,
  266. followed by a colon, followed by the value in hex in all
  267. lower case characters.'''
  268. try:
  269. hash, value = hashstr.split(':')
  270. except ValueError:
  271. if strict:
  272. raise
  273. hash = _hashlengths[len(hashstr)]
  274. value = hashstr
  275. bvalue = value.encode('ascii')
  276. if strict and len(bvalue.translate(None,
  277. string.hexdigits.lower().encode('ascii'))) != 0:
  278. raise ValueError('value has invalid hex digits (must be lower case)', value)
  279. if hash in _validhashes:
  280. return ':'.join((hash, value))
  281. raise ValueError
  282. def __len__(self):
  283. with self._ses() as session:
  284. return list(session.query(func.count(
  285. orm.MetaDataObject.uuid)))[0][0]
  286. def __iter__(self):
  287. with self._ses() as session:
  288. for i in session.query(orm.MetaDataObject.data).all():
  289. yield i.data
  290. @classmethod
  291. def load(cls, fname):
  292. engine = create_engine("sqlite+pysqlite:///%s" % fname,
  293. echo=_sql_verbose, future=True)
  294. return cls(engine)
  295. def store(self, fname):
  296. '''Write out the objects in the store to the file named
  297. fname.'''
  298. pass
  299. def _add_uuidv5(self, id, obj, session):
  300. session.execute(delete(orm.UUIDv5Table).where(
  301. orm.UUIDv5Table.uuid == id))
  302. o = orm.UUIDv5Table(uuid=id, objid=obj.uuid)
  303. session.add(o)
  304. def _lock(self, session):
  305. '''Function to issue a write to "lock" the database transaction.'''
  306. res = list(session.scalars(select(orm.Dummy).where(
  307. orm.Dummy.id == 1)))
  308. if res:
  309. session.delete(res[0])
  310. else:
  311. d = orm.Dummy(id=1)
  312. session.add(d)
  313. @staticmethod
  314. def _update_metadata_indexes(session, obj, strcache):
  315. # sqlalchemy doesn't cache inserts, so don't insert dups
  316. # ourselves
  317. propmapcache = set()
  318. # clear out old data
  319. stmt = delete(orm.PropertyMapping).where(
  320. orm.PropertyMapping.obj == obj.uuid)
  321. session.execute(stmt)
  322. props = [ x for x in obj.items() if x[0] not in {
  323. 'hashes',
  324. 'sig',
  325. 'parent_refs',
  326. } ]
  327. for k, vids in props:
  328. kid = strcache[k]
  329. if not isinstance(vids, list):
  330. vids = [ vids ]
  331. vids = [ strcache[sv] for sv in vids ]
  332. for v in vids:
  333. if (obj.uuid, kid, v) in propmapcache:
  334. continue
  335. session.add(orm.PropertyMapping(obj=obj.uuid,
  336. keyid=kid, valueid=v))
  337. propmapcache.add((obj.uuid, kid, v))
  338. def loadobj(self, obj):
  339. '''Load obj into the data store.'''
  340. obj = MDBase.create_obj(obj)
  341. with self._ses() as session:
  342. self._lock(session)
  343. oldobj = session.get(orm.MetaDataObject, obj.uuid)
  344. #if oldobj.modified > obj.modified:
  345. # return
  346. if oldobj is not None:
  347. # XXX - missing cleanup of indexes
  348. session.delete(oldobj)
  349. sobj = orm.MetaDataObject(uuid=obj.uuid, type=obj.type,
  350. modified=obj.modified, data=obj)
  351. session.add(sobj)
  352. if obj.type == 'file':
  353. objid = _makeuuid(obj.id)
  354. oldobj = self._by_id(objid, session)
  355. if oldobj is not None:
  356. # pick which obj
  357. if oldobj.modified > obj.modified:
  358. session.delete(session.get(
  359. orm.MetaDataObject,
  360. obj.uuid))
  361. obj = oldobj
  362. else:
  363. # get ride of old obj
  364. session.delete(session.get(
  365. orm.MetaDataObject,
  366. oldobj.uuid))
  367. self._add_uuidv5(obj.id, obj, session)
  368. elif obj.type == 'container':
  369. self._add_uuidv5(obj.make_id(obj.uri), obj,
  370. session)
  371. elif obj.type == 'host':
  372. o = orm.HostTable(hostid=_makeuuid(
  373. obj.hostuuid), objid=obj.uuid)
  374. session.add(o)
  375. elif obj.type == 'mapping':
  376. hostid = _makeuuid(hostuuid())
  377. maps = [ (lambda a, b: orm.HostMapping(
  378. hostid=uuid.UUID(a), objid=obj.uuid))(
  379. *x.split(':', 1)) for x in obj.mapping ]
  380. session.add_all(maps)
  381. elif obj.type == 'metadata':
  382. self._update_metadata_indexes(session, obj,
  383. StringCache(session))
  384. try:
  385. hashes = obj.hashes
  386. except AttributeError:
  387. pass
  388. else:
  389. for j in hashes:
  390. h = self.makehash(j)
  391. r = session.get(orm.HashTable,
  392. dict(hash=h, uuid=obj.uuid))
  393. if r is None:
  394. session.add(orm.HashTable(
  395. hash=h, uuid=obj.uuid))
  396. session.commit()
  397. def drop_uuid(self, uuid):
  398. uuid = _makeuuid(uuid)
  399. with self._ses() as session:
  400. obj = session.get(orm.MetaDataObject, uuid)
  401. session.delete(obj)
  402. obj = obj.data
  403. if obj.type == 'file':
  404. session.execute(delete(orm.UUIDv5Table).where(
  405. orm.UUIDv5Table.uuid == obj.id))
  406. for j in obj.hashes:
  407. h = self.makehash(j)
  408. session.execute(delete(orm.HashTable).where(
  409. orm.HashTable.hash == h and
  410. orm.HashTable.uuid == obj.uuid))
  411. session.commit()
  412. def by_id(self, id):
  413. '''Look up an object by it's UUID.'''
  414. id = _makeuuid(id)
  415. with self._ses() as session:
  416. res = self._by_id(id, session)
  417. if res is None:
  418. raise KeyError(id)
  419. return res
  420. def _by_id(self, id, session):
  421. if id.version == 5:
  422. res = session.get(orm.UUIDv5Table, id)
  423. if res is None:
  424. return
  425. id = res.objid
  426. res = session.get(orm.MetaDataObject, id)
  427. if res is None:
  428. return
  429. return res.data
  430. def by_hash(self, hash, types=None):
  431. '''Look up an object by it's hash value.
  432. types is either a list of types, or None meaning all.'''
  433. h = self.makehash(hash, strict=False)
  434. types = True if types is None else \
  435. orm.MetaDataObject.type.in_(types)
  436. sel = select(orm.MetaDataObject.data).where(
  437. orm.MetaDataObject.uuid == orm.HashTable.uuid,
  438. orm.HashTable.hash == h, types)
  439. with self._ses() as session:
  440. r = list(session.scalars(sel))
  441. return r
  442. def get_metadata(self, fname, persona, create_metadata=True):
  443. '''Get all MetaData objects for fname, or create one if
  444. not found.
  445. If a FileObject is not present, one will be created.
  446. A Persona must be passed in to create the FileObject and
  447. MetaData objects as needed.
  448. A MetaData object will be created if create_metadata is
  449. True, which is the default.
  450. Note: if a new MetaData object is created, it is not
  451. stored in the database automatically. It is expected that
  452. it will be modified and then saved, so call ObjectStore.loadobj
  453. with it to save it.
  454. '''
  455. try:
  456. fobj = self.by_file(fname, ('file',))[0]
  457. except KeyError:
  458. fobj = persona.by_file(fname)
  459. self.loadobj(fobj)
  460. # we now have the fobj, get the metadata for it.
  461. try:
  462. objs = self.by_file(fname)
  463. except KeyError:
  464. if create_metadata:
  465. objs = [ persona.MetaData(hashes=fobj.hashes) ]
  466. else:
  467. objs = [ ]
  468. return objs
  469. def get_hostmappings(self):
  470. '''Returns the tuple (lclpath, hostid, rempath) for all
  471. the mappings for this hostid.'''
  472. hostid = _makeuuid(hostuuid())
  473. sel = select(orm.MetaDataObject.data).where(
  474. orm.HostMapping.hostid == hostid,
  475. orm.HostMapping.objid == orm.MetaDataObject.uuid)
  476. res = []
  477. with self._ses() as session:
  478. # XXX - view
  479. for obj in session.scalars(sel):
  480. maps = [ (lambda a, b: (uuid.UUID(a),
  481. pathlib.Path(b).resolve()))(*x.split(':',
  482. 1)) for x in obj.mapping ]
  483. for idx, (id, path) in enumerate(maps):
  484. if hostid == id:
  485. # add other to mapping
  486. other = tuple(maps[(idx + 1) %
  487. 2])
  488. res.append((path, ) + other)
  489. return res
  490. def by_file(self, fname, types=('metadata', )):
  491. '''Return a metadata object for the file named fname.
  492. Will check the mapping database to get hashes, and possibly
  493. return that FileObject if requested.
  494. Will raise a KeyError if this file does not exist in
  495. the database.
  496. Will raise a ValueError if fname currently does not
  497. match what is in the database.
  498. '''
  499. fid = FileObject.make_id(fname)
  500. #print('bf:', repr(fid), file=_real_stderr)
  501. try:
  502. fobj = self.by_id(fid)
  503. lclfile = None
  504. except KeyError:
  505. # check mappings
  506. fname = pathlib.Path(fname).resolve()
  507. for lclpath, hostid, rempath in self.get_hostmappings():
  508. if fname.parts[:len(lclpath.parts)] == lclpath.parts:
  509. try:
  510. rempath = pathlib.Path(
  511. *rempath.parts +
  512. fname.parts[len(
  513. lclpath.parts):])
  514. fid = FileObject.make_id(
  515. rempath, hostid)
  516. fobj = self.by_id(fid)
  517. lclfile = fname
  518. break
  519. except KeyError:
  520. continue
  521. else:
  522. raise
  523. fobj.verify(lclfile)
  524. for i in fobj.hashes:
  525. j = self.by_hash(i)
  526. # Filter out non-metadata objects
  527. j = [ x for x in j if x.type in types ]
  528. if j:
  529. return j
  530. else:
  531. raise KeyError('unable to find metadata for file: %s' %
  532. repr(fname))
  533. def _readfp(fp):
  534. while True:
  535. r = fp.read(64*1024)
  536. # libarchive returns None on EOF
  537. if r == b'' or r is None:
  538. return
  539. yield r
  540. def _hashfile(fname):
  541. with open(fname, 'rb') as fp:
  542. return _hashfp(fp)
  543. def _hashfp(fp):
  544. hash = getattr(hashlib, _defaulthash)()
  545. for r in _readfp(fp):
  546. hash.update(r)
  547. return '%s:%s' % (_defaulthash, hash.hexdigest())
  548. class Host(MDBase):
  549. _type = 'host'
  550. _class_instance_properties = {
  551. 'hostuuid': _makeuuid,
  552. }
  553. class Mapping(MDBase):
  554. _type = 'mapping'
  555. class FileObject(MDBase):
  556. _type = 'file'
  557. _class_instance_properties = {
  558. 'hostid': _makeuuid,
  559. 'id': _makeuuid,
  560. 'mtime': _makedatetime,
  561. }
  562. @property
  563. def fullname(self):
  564. return os.path.join(self.dir, self.filename)
  565. @staticmethod
  566. def prep_mapping(mapping):
  567. r = collections.defaultdict(lambda: [])
  568. for lclp, hid, remp in mapping:
  569. r[hid].append((pathlib.PosixPath(lclp),
  570. pathlib.PosixPath(remp)))
  571. return r
  572. def get_lcl_name(self, mapping):
  573. '''Using mapping (obtained from the object store's
  574. get_hostmappings method and passed through local prep_mapping
  575. method), try to map the file to a local path.
  576. if not possible, returned will be hostid:remotepath.
  577. '''
  578. lclhostid = hostuuid()
  579. #_debprint('gln:', repr(self), repr(mapping), repr(lclhostid))
  580. if self.hostid == lclhostid:
  581. return self.fullname
  582. for lclp, remp in mapping[self.hostid]:
  583. fname = pathlib.PosixPath(self.fullname)
  584. if fname.parts[:len(remp.parts)] == remp.parts:
  585. return pathlib.Path(
  586. *lclp.parts +
  587. fname.parts[len(
  588. remp.parts):])
  589. else:
  590. return str(self.hostid) + ':' + self.fullname
  591. @staticmethod
  592. def make_id(fname, hostid=None):
  593. '''Take a local file name, and make the id for it. Note that
  594. converts from the local path separator to a forward slash so
  595. that it will be the same between Windows and Unix systems.'''
  596. if hostid is None:
  597. hostid = hostuuid()
  598. fname = os.path.realpath(fname)
  599. return uuid.uuid5(_NAMESPACE_MEDASHARE_PATH,
  600. str(hostid) + '/'.join(os.path.split(fname)))
  601. _statsymbtoname = { getattr(stat, x): 'stat.' + x for x in dir(stat) if x.startswith('S_') }
  602. @classmethod
  603. def _modetosymbolic(cls, mode): # pragma: no cover
  604. r = []
  605. while mode:
  606. nbit = -mode & mode
  607. r.append(cls._statsymbtoname[nbit])
  608. mode = mode & ~nbit
  609. return '|'.join(r)
  610. @classmethod
  611. def _real_stat_repr(cls, st): # pragma: no cover
  612. return 'os.stat_result' \
  613. '((%s, %d, %d, %d, %d, %d, %d, %d, %.6f, %d))' % \
  614. (cls._modetosymbolic(st.st_mode), 10, 100, 1, 100, 100,
  615. st.st_size, st.st_atime, st.st_mtime, st.st_ctime)
  616. @classmethod
  617. def from_file(cls, filename, created_by_ref):
  618. filename = os.path.abspath(filename)
  619. s = os.stat(filename)
  620. # keep so that when new files are added, it's easy to get stat
  621. #_debprint(repr(filename), cls._real_stat_repr(s))
  622. # XXX - race here, fix w/ checking mtime before/after?
  623. obj = {
  624. 'created_by_ref': created_by_ref,
  625. 'hostid': hostuuid(),
  626. 'dir': os.path.dirname(filename),
  627. 'filename': os.path.basename(filename),
  628. 'id': cls.make_id(filename),
  629. 'mtime': datetime.datetime.fromtimestamp(s.st_mtime,
  630. tz=datetime.timezone.utc),
  631. 'size': s.st_size,
  632. 'hashes': [ _hashfile(filename), ],
  633. }
  634. return cls(obj)
  635. def verify(self, lclfile=None):
  636. '''Verify that this FileObject is still valid. It will
  637. by default, only do a mtime verification.
  638. It will raise a ValueError if the file does not match.'''
  639. if lclfile is None:
  640. s = os.stat(os.path.join(self.dir, self.filename))
  641. else:
  642. s = os.stat(lclfile)
  643. mtimets = datetime.datetime.fromtimestamp(s.st_mtime,
  644. tz=datetime.timezone.utc).timestamp()
  645. #print(repr(self), repr(s), s.st_mtime, file=_real_stderr)
  646. if self.mtime.timestamp() != mtimets or \
  647. self.size != s.st_size:
  648. _debprint('times:', self.mtime.timestamp(), mtimets)
  649. raise ValueError('file %s has changed' %
  650. repr(self.filename))
  651. class Container(MDBase):
  652. _type = 'container'
  653. _common_optional = MDBase._common_optional | set([ 'uri' ])
  654. @staticmethod
  655. def make_id(uri):
  656. return uuid.uuid5(_NAMESPACE_MEDASHARE_CONTAINER, uri)
  657. def enumeratedir(_dir, created_by_ref):
  658. '''Enumerate all the files and directories (not recursive) in _dir.
  659. Returned is a list of FileObjects.'''
  660. return [FileObject.from_file(os.path.join(_dir, x),
  661. created_by_ref) for x in sorted(os.listdir(_dir)) if not
  662. os.path.isdir(os.path.join(_dir, x)) ]
  663. def _get_paths(options):
  664. fnames = (
  665. '.medashare_identity.pasn1',
  666. '.medashare_store.sqlite3',
  667. '.medashare_cache.pasn1' )
  668. if 'MEDASHARE_PATH' in os.environ:
  669. return ( os.path.expanduser(
  670. os.path.join(os.environ['MEDASHARE_PATH'], x)) for x in
  671. fnames )
  672. return ( os.path.expanduser('~/' + x) for x in fnames )
  673. class StringCache:
  674. def __init__(self, session):
  675. self._ses = session
  676. self._cache = {}
  677. def __getitem__(self, k):
  678. try:
  679. return self._cache[k]
  680. except KeyError:
  681. pass
  682. v = self._ses.execute(select(orm.StringTable.id).where(
  683. orm.StringTable.str == k)).first()
  684. if v is None:
  685. # not present, insert it
  686. st = self._ses.add(orm.StringTable(str=k))
  687. v = self._ses.execute(select(orm.StringTable.id)
  688. .where(orm.StringTable.str == k)).first()
  689. v = v[0]
  690. self._cache[k] = v
  691. return v
  692. def init_datastructs(f):
  693. @functools.wraps(f)
  694. def wrapper(options):
  695. identfname, storefname, cachefname = _get_paths(options)
  696. # create the persona
  697. try:
  698. persona = Persona.load(identfname)
  699. except FileNotFoundError:
  700. print('ERROR: Identity not created, create w/ genident.',
  701. file=sys.stderr)
  702. sys.exit(1)
  703. # create the object store
  704. engine = create_engine("sqlite+pysqlite:///%s" % storefname,
  705. echo=_sql_verbose, future=True)
  706. objstr = ObjectStore(engine)
  707. # create the cache
  708. cache = TagCache.load(cachefname)
  709. try:
  710. r = f(options, persona, objstr, cache)
  711. # per: https://www.sqlite.org/lang_analyze.html
  712. with engine.connect() as con:
  713. con.execute(text('PRAGMA analysis_limit = 400;'))
  714. con.execute(text('PRAGMA optimize;'))
  715. con.commit()
  716. return r
  717. finally:
  718. if cache.modified:
  719. cache.store(cachefname)
  720. return wrapper
  721. def cmd_genident(options):
  722. identfname, _, _ = _get_paths(options)
  723. if os.path.exists(identfname):
  724. print('Error: Identity already created.', file=sys.stderr)
  725. sys.exit(1)
  726. persona = Persona()
  727. persona.generate_key()
  728. persona.new_version(*(x.split('=', 1) for x in options.tagvalue))
  729. persona.store(identfname)
  730. def cmd_ident(options):
  731. identfname, _, _ = _get_paths(options)
  732. persona = Persona.load(identfname)
  733. if options.tagvalue:
  734. persona.new_version(*(x.split('=', 1) for x in
  735. options.tagvalue))
  736. persona.store(identfname)
  737. else:
  738. ident = persona.get_identity()
  739. for k, v in _iterdictlist(ident, skipcommon=False):
  740. print('%s:\t%s' % (k, v))
  741. def cmd_pubkey(options):
  742. identfname, _, _ = _get_paths(options)
  743. persona = Persona.load(identfname)
  744. print(persona.get_pubkey())
  745. @init_datastructs
  746. def cmd_modify(options, persona, objstr, cache):
  747. # because of how argparse works, only one file will be collected
  748. # multiple files will end up in modtagvalues, so we need to
  749. # find and move them.
  750. for idx, i in enumerate(options.modtagvalues):
  751. if i[0] not in { '+', '-' }:
  752. # move remaining files
  753. options.files[0:0] = options.modtagvalues[idx:]
  754. del options.modtagvalues[idx:]
  755. break
  756. props = [[ x[0] ] + x[1:].split('=', 1) for x in options.modtagvalues]
  757. if any(x[0] not in ('+', '-') for x in props):
  758. print('ERROR: tag needs to start with a "+" (add) or a "-" (remove).', file=sys.stderr)
  759. sys.exit(1)
  760. badtags = list(x[1] for x in props if x[1] in (MDBase._common_names |
  761. MDBase._common_optional))
  762. if any(badtags):
  763. print('ERROR: invalid tag%s: %s.' % ( 's' if
  764. len(badtags) > 1 else '', repr(badtags)), file=sys.stderr)
  765. sys.exit(1)
  766. adds = [ x[1:] for x in props if x[0] == '+' ]
  767. if any((len(x) != 2 for x in adds)):
  768. print('ERROR: invalid tag, needs an "=".', file=sys.stderr)
  769. sys.exit(1)
  770. dels = [ x[1:] for x in props if x[0] == '-' ]
  771. for i in options.files:
  772. #print('a:', repr(i), file=_real_stderr)
  773. try:
  774. objs = objstr.get_metadata(i, persona)
  775. #print('d:', repr(i), repr(objs), file=_real_stderr)
  776. except FileNotFoundError:
  777. print('ERROR: file not found: %s, or invalid tag specification.' % repr(i), file=sys.stderr)
  778. sys.exit(1)
  779. for j in objs:
  780. #print('c:', repr(j), file=_real_stderr)
  781. # make into key/values
  782. # copy as we modify it later, which is bad
  783. obj = j.__to_dict__().copy()
  784. # delete tags
  785. for k in dels:
  786. try:
  787. key, v = k
  788. except ValueError:
  789. try:
  790. del obj[k[0]]
  791. except KeyError:
  792. pass
  793. else:
  794. obj[key].remove(v)
  795. # add tags
  796. uniqify = set()
  797. for k, v in adds:
  798. obj.setdefault(k, []).append(v)
  799. if k in j._uniq_properties:
  800. uniqify.add(k)
  801. for k in uniqify:
  802. obj[k] = list(set(obj[k]))
  803. #print('a:', repr(obj), file=_real_stderr)
  804. del obj['modified']
  805. nobj = MDBase.create_obj(obj)
  806. objstr.loadobj(nobj)
  807. def printhost(host):
  808. print('%s\t%s' % (host.name, host.hostuuid))
  809. @init_datastructs
  810. def cmd_mapping(options, persona, objstr, cache):
  811. if options.mapping is None:
  812. maps = objstr.get_by_type('mapping')
  813. maps = (' <-> '.join(i['mapping']) for i in maps)
  814. for i in maps:
  815. print(i)
  816. return
  817. parts = [ x.split(':', 1) for x in options.mapping ]
  818. if len(parts[0]) == 1:
  819. parts[0] = [ str(hostuuid()), parts[0][0] ]
  820. if parts[0][0] == str(hostuuid()):
  821. parts[0][1] = str(pathlib.Path(parts[0][1]).resolve())
  822. if parts[1][1][0] != '/':
  823. print('ERROR: host path must be absolute, is %s.' %
  824. repr(parts[1][1][0]), file=sys.stderr)
  825. sys.exit(1)
  826. try:
  827. [ objstr.get_host(x[0]) for x in parts ]
  828. except KeyError as e:
  829. print('ERROR: Unable to find host %s' %
  830. str(e.args[0]), file=sys.stderr)
  831. sys.exit(1)
  832. m = persona.Mapping(mapping=[ ':'.join(x) for x in parts ])
  833. objstr.loadobj(m)
  834. @init_datastructs
  835. def cmd_hosts(options, persona, objstr, cache):
  836. selfuuid = hostuuid()
  837. try:
  838. host = objstr.get_host(selfuuid)
  839. except KeyError:
  840. host = persona.Host(name=socket.gethostname(), hostuuid=selfuuid)
  841. objstr.loadobj(host)
  842. printhost(host)
  843. hosts = objstr.get_hosts()
  844. for i in hosts:
  845. if i == host:
  846. continue
  847. printhost(i)
  848. def genstartstop(cnt, idx):
  849. idx = min(idx, cnt - 10)
  850. idx = max(0, idx)
  851. maxstart = max(0, cnt - 20)
  852. startidx = min(max(0, idx - 10), maxstart)
  853. endidx = min(cnt, startidx + 20)
  854. return startidx, endidx
  855. def getnextfile(files, idx):
  856. # original data incase of abort
  857. origfiles = files
  858. origidx = idx
  859. # current selection (last file or dir)
  860. curselidx = origidx
  861. currentcnt = None
  862. while True:
  863. if len(files) != currentcnt:
  864. currentcnt = len(files)
  865. maxidx = max(0, currentcnt - 10)
  866. idx = min(maxidx, idx)
  867. startidx, endidx = genstartstop(currentcnt, idx)
  868. subset = files[startidx:endidx]
  869. selfile = -1 if curselidx < startidx or curselidx >= startidx + \
  870. 20 else curselidx - startidx
  871. print('%2d) Parent directory' % 0)
  872. for i, f in enumerate(subset):
  873. print('%2d)%1s%s%s' % (i + 1, '*' if i == selfile else '', repr(str(f)), '/' if f.is_dir() else ''))
  874. print('P) Previous page')
  875. print('N) Next page')
  876. print('A) Abort')
  877. print('Selection:')
  878. inp = sys.stdin.readline().strip()
  879. if inp.lower() == 'p':
  880. idx = max(0, idx - 19)
  881. continue
  882. if inp.lower() == 'n':
  883. idx = min(currentcnt - 1, idx + 19)
  884. continue
  885. if inp.lower() == 'a':
  886. return origfiles, origidx
  887. try:
  888. inp = int(inp)
  889. except ValueError:
  890. print('Invalid selection.')
  891. continue
  892. if inp == 0:
  893. curdir = files[idx].parent
  894. files = sorted(files[idx].parent.parent.iterdir())
  895. idx = curselidx = files.index(curdir)
  896. continue
  897. if inp < 1 or inp > len(subset):
  898. print('Invalid selection.')
  899. continue
  900. newidx = startidx - 1 + inp
  901. if files[newidx].is_dir():
  902. files = sorted(files[newidx].iterdir())
  903. curselidx = idx = 0
  904. continue
  905. return files, newidx
  906. def checkforfile(objstr, curfile, ask=False):
  907. try:
  908. fobj = objstr.by_file(curfile, ('file',))
  909. except (ValueError, KeyError):
  910. if not ask:
  911. return
  912. while True:
  913. print('file unknown, hash(y/n)?')
  914. inp = sys.stdin.readline().strip().lower()
  915. if inp == 'n':
  916. return
  917. if inp == 'y':
  918. break
  919. try:
  920. fobj = persona.by_file(curfile)
  921. except (FileNotFoundError, KeyError) as e:
  922. print('ERROR: file not found: %s' % repr(curfile), file=sys.stderr)
  923. return
  924. else:
  925. objstr.loadobj(fobj)
  926. return fobj
  927. from contextlib import contextmanager
  928. import time
  929. @contextmanager
  930. def timeblock(tag):
  931. s = time.time()
  932. try:
  933. yield
  934. finally:
  935. e = time.time()
  936. _debprint(tag, e - s)
  937. @init_datastructs
  938. def cmd_interactive(options, persona, objstr, cache):
  939. files = [ pathlib.Path(x) for x in options.files ]
  940. cache.count = 15
  941. autoskip = True
  942. idx = 0
  943. if not files:
  944. files = sorted(pathlib.Path('.').iterdir())
  945. while True:
  946. with timeblock('checkfile'):
  947. curfile = files[idx]
  948. fobj = checkforfile(objstr, curfile, not autoskip)
  949. #_debprint(repr(fobj))
  950. if fobj is None and autoskip and idx > 0 and idx < len(files) - 1:
  951. # if we are auto skipping, and within range, continue
  952. if inp == '1':
  953. idx = max(0, idx - 1)
  954. continue
  955. if inp == '2':
  956. idx = min(len(files) - 1, idx + 1)
  957. continue
  958. print('Current: %s' % repr(str(curfile)))
  959. with timeblock('byfile'):
  960. if fobj is None:
  961. print('No file object for this file.')
  962. else:
  963. try:
  964. objs = objstr.by_file(curfile)
  965. except KeyError:
  966. print('No tags or metadata object for this file.')
  967. else:
  968. for k, v in _iterdictlist(objs[0]):
  969. if k in { 'sig', 'hashes' }:
  970. continue
  971. print('%s:\t%s' % (k, v))
  972. if idx == 0:
  973. print('1) No previous file')
  974. else:
  975. print('1) Previous: %s' % repr(str(files[idx - 1])))
  976. if idx + 1 == len(files):
  977. print('2) No next file')
  978. else:
  979. print('2) Next: %s' % repr(str(files[idx + 1])))
  980. print('3) List files')
  981. print('4) Browse directory of file.')
  982. print('5) Browse original list of files.')
  983. print('6) Add new tag.')
  984. print('7) Open file.')
  985. print('8) Turn auto skip %s' % 'off' if autoskip else 'on')
  986. tags = cache.tags()
  987. for pos, (tag, value) in enumerate(tags):
  988. print('%s) %s=%s' % (string.ascii_lowercase[pos], tag, value))
  989. print('Q) Quit')
  990. print('Select option: ')
  991. inp = sys.stdin.readline().strip()
  992. if not inp:
  993. continue
  994. elif inp == '1':
  995. idx = max(0, idx - 1)
  996. continue
  997. elif inp == '2':
  998. idx = min(len(files) - 1, idx + 1)
  999. continue
  1000. elif inp == '3':
  1001. files, idx = getnextfile(files, idx)
  1002. continue
  1003. elif inp == '4':
  1004. files = sorted(curfile.parent.iterdir())
  1005. try:
  1006. idx = files.index(curfile)
  1007. except ValueError:
  1008. print('WARNING: File no longer present.')
  1009. idx = 0
  1010. continue
  1011. elif inp == '5':
  1012. files = [ pathlib.Path(x) for x in options.files ]
  1013. try:
  1014. idx = files.index(curfile)
  1015. except ValueError:
  1016. print('WARNING: File not present.')
  1017. idx = 0
  1018. continue
  1019. elif inp == '6':
  1020. print('Tag?')
  1021. try:
  1022. tag, value = sys.stdin.readline().strip().split('=', 1)
  1023. except ValueError:
  1024. print('Invalid tag, no "=".')
  1025. else:
  1026. cache.add((tag, value))
  1027. metadata = objstr.get_metadata(curfile, persona)[0]
  1028. metadata = metadata.new_version((tag, value))
  1029. objstr.loadobj(metadata)
  1030. continue
  1031. elif inp == '7':
  1032. subprocess.run(('open', curfile))
  1033. continue
  1034. elif inp.lower() == 'q':
  1035. break
  1036. try:
  1037. i = string.ascii_lowercase.index(inp.lower())
  1038. cache.add(tags[i])
  1039. except (ValueError, IndexError):
  1040. pass
  1041. else:
  1042. metadata = objstr.get_metadata(curfile, persona)[0]
  1043. metadata = metadata.new_version(tags[i])
  1044. objstr.loadobj(metadata)
  1045. continue
  1046. print('Invalid selection.')
  1047. @init_datastructs
  1048. def cmd_dump(options, persona, objstr, cache):
  1049. if options.dump_uuids or options.dump_hashes:
  1050. for i in options.dump_uuids:
  1051. print(objstr.by_id(i).encode('json'))
  1052. for i in options.dump_hashes:
  1053. for j in objstr.by_hash(i):
  1054. print(j.encode('json'))
  1055. return
  1056. print(persona.get_identity().encode('json'))
  1057. for i in objstr:
  1058. print(i.encode('json'))
  1059. def cmd_auto(options):
  1060. for i in options.files[:]:
  1061. mf = detect_from_filename(i)
  1062. primary = mf[0].split('/', 1)[0]
  1063. mt = mf[0]
  1064. if primary == 'text':
  1065. mt += '; charset=%s' % mf[1]
  1066. print('Set:')
  1067. print('\tmimetype:\t%s' % mt)
  1068. print()
  1069. print('Apply (y/N)?')
  1070. inp = sys.stdin.readline()
  1071. if inp.strip().lower() in ('y', 'yes'):
  1072. options.modtagvalues = [ '-mimetype', '+mimetype=%s' % mt ]
  1073. options.files = [ i ]
  1074. cmd_modify(options)
  1075. @init_datastructs
  1076. def cmd_list(options, persona, objstr, cache):
  1077. exit = 0
  1078. for i in options.files:
  1079. try:
  1080. objs = objstr.by_file(i)
  1081. except (ValueError, KeyError):
  1082. # create the file, it may have the same hash
  1083. # as something else
  1084. try:
  1085. fobj = persona.by_file(i)
  1086. objstr.loadobj(fobj)
  1087. objs = objstr.by_file(i)
  1088. except (FileNotFoundError, KeyError) as e:
  1089. if options.empty and options.json:
  1090. print('{}')
  1091. continue
  1092. print('ERROR: file not found: %s' % repr(i), file=sys.stderr)
  1093. exit = 1
  1094. continue
  1095. for j in objstr.by_file(i):
  1096. if options.json:
  1097. print(j.encode('json'))
  1098. else:
  1099. for k, v in _iterdictlist(j):
  1100. print('%s:\t%s' % (k, v))
  1101. if exit:
  1102. sys.exit(exit)
  1103. def handle_bittorrent(fname, persona, objstr):
  1104. with open(fname, 'rb') as fp:
  1105. torrent = bencode.bdecode(fp.read())
  1106. bencodedinfo = bencode.bencode(torrent['info'])
  1107. infohash = hashlib.sha1(bencodedinfo).hexdigest()
  1108. # XXX - not entirely happy w/ URI
  1109. uri = 'magnet:?xt=urn:btih:%s&dn=%s' % (infohash,
  1110. torrent['info']['name'].decode('utf-8'))
  1111. try:
  1112. cont = objstr.by_id(Container.make_id(uri))
  1113. except KeyError:
  1114. pass
  1115. else:
  1116. if 'incomplete' not in cont:
  1117. print('Warning, container already complete, skipping %s.' % repr(fname), file=sys.stderr)
  1118. return
  1119. good, bad = validate_file(fname)
  1120. if bad:
  1121. print('Warning, incomple/invalid files, not added for %s:' %
  1122. repr(fname), file=sys.stderr)
  1123. print('\n'.join('\t%s' %
  1124. repr(str(pathlib.Path(*x.parts[1:]))) for x in
  1125. sorted(bad)), file=sys.stderr)
  1126. files = []
  1127. hashes = []
  1128. for j in sorted(good):
  1129. files.append(str(pathlib.PosixPath(*j.parts[1:])))
  1130. try:
  1131. fobj = objstr.by_file(j, ('file',))[0]
  1132. except:
  1133. fobj = persona.by_file(j)
  1134. objstr.loadobj(fobj)
  1135. # XXX - ensure only one is added?
  1136. hashes.extend(fobj.hashes)
  1137. kwargs = dict(files=files, hashes=hashes,
  1138. uri=uri)
  1139. if bad:
  1140. kwargs['incomplete'] = True
  1141. # XXX - doesn't combine files/hashes, that is if a
  1142. # Container has one set of good files, and then the
  1143. # next scan has a different set, only the second set
  1144. # will be present, not any from the first set.
  1145. try:
  1146. cont = objstr.by_id(Container.make_id(uri))
  1147. cont = cont.new_version(dels=() if bad
  1148. else ('incomplete',), replaces=kwargs.items())
  1149. except KeyError:
  1150. cont = persona.Container(**kwargs)
  1151. objstr.loadobj(cont)
  1152. def handle_archive(fname, persona, objstr):
  1153. with libarchive.Archive(fname) as arch:
  1154. files = []
  1155. hashes = []
  1156. for i in arch:
  1157. if not i.isfile():
  1158. continue
  1159. files.append(i.pathname)
  1160. with arch.readstream(i.size) as fp:
  1161. hashes.append(_hashfp(fp))
  1162. try:
  1163. fobj = objstr.by_file(fname, ('file',))[0]
  1164. except:
  1165. fobj = persona.by_file(fname)
  1166. objstr.loadobj(fobj)
  1167. uri = _makehashuri(fobj.hashes[0])
  1168. kwargs = dict(files=files, hashes=hashes,
  1169. uri=uri)
  1170. try:
  1171. cont = objstr.by_id(Container.make_id(uri))
  1172. # XXX - only update when different, check uri
  1173. cont = cont.new_version(replaces=kwargs.items())
  1174. except KeyError:
  1175. cont = persona.Container(**kwargs)
  1176. objstr.loadobj(cont)
  1177. _container_mapping = {
  1178. 'application/x-bittorrent': handle_bittorrent,
  1179. 'application/x-tar': handle_archive,
  1180. 'application/zip': handle_archive,
  1181. }
  1182. @init_datastructs
  1183. def cmd_container(options, persona, objstr, cache):
  1184. for i in options.files:
  1185. mf = detect_from_filename(i)
  1186. #_debprint('mf:', repr(mf))
  1187. fun = _container_mapping[mf.mime_type]
  1188. fun(i, persona, objstr)
  1189. def _json_objstream(fp):
  1190. inp = fp.read()
  1191. jd = json.JSONDecoder()
  1192. while inp:
  1193. inp = inp.strip()
  1194. jobj, endpos = jd.raw_decode(inp)
  1195. yield jobj
  1196. inp = inp[endpos:]
  1197. @init_datastructs
  1198. def cmd_import(options, persona, objstr, cache):
  1199. for jobj in _json_objstream(sys.stdin):
  1200. if options.sign:
  1201. cbr = _makeuuid(jobj['created_by_ref'])
  1202. if cbr != persona.uuid:
  1203. # new owner
  1204. jobj['created_by_ref'] = persona.uuid
  1205. # drop old parts
  1206. jobj.pop('uuid', None)
  1207. jobj.pop('modified', None)
  1208. obj = MDBase.create_obj(jobj)
  1209. if options.sign:
  1210. obj = persona.sign(obj)
  1211. objstr.loadobj(obj)
  1212. @init_datastructs
  1213. def cmd_drop(options, persona, objstr, cache):
  1214. for i in options.uuids:
  1215. objstr.drop_uuid(i)
  1216. @init_datastructs
  1217. def cmd_search(options, persona, objstr, cache):
  1218. args = options.args.copy()
  1219. _type = args.pop(0)
  1220. if _type == 'tags':
  1221. propmap = aliased(orm.PropertyMapping)
  1222. skeymap = aliased(orm.StringTable)
  1223. svaluemap = aliased(orm.StringTable)
  1224. q = select(skeymap.str, svaluemap.str).where(propmap.keyid == skeymap.id).where(skeymap.str == args[0]).where(propmap.valueid == svaluemap.id).distinct().order_by(svaluemap.str)
  1225. _debprint(repr(q))
  1226. with objstr._ses() as session:
  1227. for k, v in session.execute(q):
  1228. print('%s=%s' % (k, v))
  1229. return
  1230. if _type != 'file':
  1231. print('unknown search type: %s' % repr(_type),
  1232. file=sys.stderr)
  1233. sys.exit(1)
  1234. searches = [ (x[0], ) + tuple(x[1:].split('=', 1)) for x in args ]
  1235. #_debprint(repr(searches))
  1236. if not searches:
  1237. # just dump everything
  1238. mdofile = aliased(orm.MetaDataObject)
  1239. sel = select(mdofile.data).where(
  1240. # we are operating on files
  1241. mdofile.type == 'file')
  1242. sel = sel.execution_options(yield_per=10)
  1243. with objstr._ses() as session:
  1244. r = ( x[0] for x in session.execute(sel) )
  1245. if _type == 'file':
  1246. mapping = FileObject.prep_mapping(
  1247. objstr.get_hostmappings())
  1248. r = ( x.get_lcl_name(mapping) for x in r )
  1249. else:
  1250. raise ValueError('unhandled type: %s' % repr(_type))
  1251. for i in r:
  1252. print(i)
  1253. return
  1254. propmap = aliased(orm.PropertyMapping)
  1255. # propobj only returns what can match query
  1256. propobj = select(propmap.obj).distinct()
  1257. onlyexclusions = True
  1258. for i in searches:
  1259. propmapsub = aliased(orm.PropertyMapping)
  1260. skeymap = aliased(orm.StringTable)
  1261. svaluemap = aliased(orm.StringTable)
  1262. origvalue = '='.join(i[1:])
  1263. try:
  1264. op, key, value = i
  1265. except ValueError:
  1266. op, key = i
  1267. value = None
  1268. # handle meta tree
  1269. if key.startswith('meta:'):
  1270. mat = _metaopre.match(origvalue)
  1271. if not mat:
  1272. print('invalid meta: specification: %s' % repr(origvalue),
  1273. file=sys.stderr)
  1274. sys.exit(1)
  1275. #print(repr(mat), repr(mat.groups()), file=_real_stderr)
  1276. mdo = aliased(orm.MetaDataObject)
  1277. metavalue = _metavalueconv[mat.group('key')](mat.group('val'))
  1278. subq = select(propmapsub.obj).where(propmapsub.obj == mdo.uuid,
  1279. _metaoplookup[mat.group('op')](mdo.modified, metavalue))
  1280. else:
  1281. subq = select(propmapsub.obj).where(
  1282. # that match the key
  1283. propmapsub.keyid == skeymap.id, skeymap.str == key)
  1284. if value is not None:
  1285. subq = subq.where(propmapsub.valueid == svaluemap.id,
  1286. svaluemap.str == value)
  1287. #subq = subq.subquery()
  1288. if op == '+':
  1289. onlyexclusions = False
  1290. propobj = propobj.where(propmap.obj.in_(subq))
  1291. elif op == '-':
  1292. propobj = propobj.where(propmap.obj.notin_(subq))
  1293. else:
  1294. raise ValueError('unhandled op: %s' % repr(op))
  1295. # propobj should have all the ones we need selected, map back to
  1296. # the object we need
  1297. # base object (file)
  1298. mdofile = aliased(orm.MetaDataObject)
  1299. # hashes of base object
  1300. htfile = aliased(orm.HashTable)
  1301. # hashes to metadata objects
  1302. htmd = aliased(orm.HashTable)
  1303. # metadataobjects
  1304. mdomd = aliased(orm.MetaDataObject)
  1305. # Don't know if distinct is needed/warrented for the
  1306. # in_ sub queries
  1307. sel = select(mdofile.data).where(
  1308. # we are operating on files
  1309. mdofile.type == 'file',
  1310. # we get all the hashes for the files
  1311. mdofile.uuid.in_(
  1312. select(htfile.uuid).where(
  1313. htfile.hash == htmd.hash,
  1314. # we get all the hashes for selected metadata
  1315. htmd.uuid.in_(
  1316. select(mdomd.uuid).where(
  1317. mdomd.type == 'metadata',
  1318. mdomd.uuid.in_(propobj)
  1319. )
  1320. )
  1321. )
  1322. )
  1323. )
  1324. if onlyexclusions:
  1325. # add in all the files that doesn't have metadata
  1326. # base object (file)
  1327. mdofile = aliased(orm.MetaDataObject)
  1328. # hashes of base object
  1329. htfile = aliased(orm.HashTable)
  1330. # hashes to metadata objects
  1331. htmd = aliased(orm.HashTable)
  1332. # metadataobjects
  1333. mdomd = aliased(orm.MetaDataObject)
  1334. # query for hashes w/o metadata
  1335. selwomd = select(mdofile.data).where(
  1336. # we are operating on files
  1337. mdofile.type == 'file',
  1338. # we get all the hashes for the files
  1339. mdofile.uuid == htfile.uuid,
  1340. htfile.hash.notin_(
  1341. select(htmd.hash).where(
  1342. htmd.uuid == mdomd.uuid, mdomd.type == 'metadata'
  1343. )
  1344. )
  1345. )
  1346. sel = sel.union(selwomd)
  1347. sel = sel.execution_options(yield_per=10)
  1348. #_debprint('sel:', _getquery(sel, objstr))
  1349. #print('sel:', _getquery(sel, objstr), file=_real_stderr)
  1350. with objstr._ses() as session:
  1351. r = ( x[0] for x in session.execute(sel) )
  1352. if _type == 'file':
  1353. mapping = FileObject.prep_mapping(
  1354. objstr.get_hostmappings())
  1355. r = ( x.get_lcl_name(mapping) for x in r )
  1356. else:
  1357. raise ValueError('unhandled type: %s' % repr(_type))
  1358. for i in r:
  1359. print(i)
  1360. def main():
  1361. import argparse
  1362. parser = argparse.ArgumentParser()
  1363. parser.add_argument('--debug', action='store_true',
  1364. default=False, help='enable debug output')
  1365. parser.add_argument('--db', '-d', type=str,
  1366. help='base name for storage')
  1367. subparsers = parser.add_subparsers(title='subcommands',
  1368. description='valid subcommands', help='additional help')
  1369. parser_help = subparsers.add_parser('help', help='get help')
  1370. parser_help.set_defaults(func=lambda *args: (parser.print_help(), sys.exit(0)))
  1371. parser_gi = subparsers.add_parser('genident', help='generate identity')
  1372. parser_gi.add_argument('tagvalue', nargs='+',
  1373. help='add the arg as metadata for the identity, tag=[value]')
  1374. parser_gi.set_defaults(func=cmd_genident)
  1375. parser_i = subparsers.add_parser('ident', help='update identity')
  1376. parser_i.add_argument('tagvalue', nargs='*',
  1377. help='add the arg as metadata for the identity, tag=[value]')
  1378. parser_i.set_defaults(func=cmd_ident)
  1379. parser_pubkey = subparsers.add_parser('pubkey',
  1380. help='print public key of identity')
  1381. parser_pubkey.set_defaults(func=cmd_pubkey)
  1382. # used so that - isn't treated as an option
  1383. parser_mod = subparsers.add_parser('modify',
  1384. help='modify tags on file(s)', prefix_chars='@')
  1385. parser_mod.add_argument('modtagvalues', nargs='+',
  1386. help='add (+) or delete (-) the tag=[value], for the specified files')
  1387. parser_mod.add_argument('files', nargs='+',
  1388. help='files to modify')
  1389. parser_mod.set_defaults(func=cmd_modify)
  1390. parser_auto = subparsers.add_parser('auto',
  1391. help='automatic detection of file properties')
  1392. parser_auto.add_argument('files', nargs='+',
  1393. help='files to modify')
  1394. parser_auto.set_defaults(func=cmd_auto)
  1395. parser_list = subparsers.add_parser('list', help='list tags on file(s)')
  1396. parser_list.add_argument('--json', action='store_true',
  1397. help='output data as series of JSON objects')
  1398. parser_list.add_argument('--empty', action='store_true',
  1399. help='output an empty JSON object if file not found or not metadata')
  1400. parser_list.add_argument('files', nargs='+',
  1401. help='files to modify')
  1402. parser_list.set_defaults(func=cmd_list)
  1403. parser_container = subparsers.add_parser('container',
  1404. help='file is examined as a container and the internal files imported as entries')
  1405. parser_container.add_argument('files', nargs='+',
  1406. help='files to modify')
  1407. parser_container.set_defaults(func=cmd_container)
  1408. parser_hosts = subparsers.add_parser('hosts',
  1409. help='dump all the hosts, self is always first')
  1410. parser_hosts.set_defaults(func=cmd_hosts)
  1411. parser_mapping = subparsers.add_parser('mapping',
  1412. help='list mappings, or create a mapping')
  1413. parser_mapping.add_argument('--create', dest='mapping', nargs=2,
  1414. help='mapping to add, host|hostuuid:path host|hostuuid:path')
  1415. parser_mapping.set_defaults(func=cmd_mapping)
  1416. parser_interactive = subparsers.add_parser('interactive',
  1417. help='start in interactive mode')
  1418. parser_interactive.add_argument('files', nargs='*',
  1419. help='files to work with')
  1420. parser_interactive.set_defaults(func=cmd_interactive)
  1421. parser_dump = subparsers.add_parser('dump', help='dump all the objects')
  1422. parser_dump.add_argument('--uuid', dest='dump_uuids', action='append',
  1423. default=[],
  1424. help='dump the object with the specified UUID')
  1425. parser_dump.add_argument('--hash', dest='dump_hashes', action='append',
  1426. default=[],
  1427. help='dump the object(s) associated w/ the specified hash')
  1428. parser_dump.set_defaults(func=cmd_dump)
  1429. parser_import = subparsers.add_parser('import',
  1430. help='import objects encoded as json')
  1431. parser_import.add_argument('--sign', action='store_true',
  1432. help='import as new identity, and sign objects (if created_by_ref is different, new uuid is created)')
  1433. parser_import.set_defaults(func=cmd_import)
  1434. parser_drop = subparsers.add_parser('drop',
  1435. help='drop the object specified by UUID')
  1436. parser_drop.add_argument('uuids', nargs='+',
  1437. help='UUID of object to drop')
  1438. parser_drop.set_defaults(func=cmd_drop)
  1439. parser_search = subparsers.add_parser('search',
  1440. help='find objects', prefix_chars='@')
  1441. parser_search.add_argument('args', nargs='+',
  1442. help='args')
  1443. parser_search.set_defaults(func=cmd_search)
  1444. options = parser.parse_args()
  1445. if options.debug:
  1446. enable_debug()
  1447. else:
  1448. disable_debug()
  1449. try:
  1450. fun = options.func
  1451. except AttributeError:
  1452. parser.print_help()
  1453. sys.exit(0)
  1454. fun(options)
  1455. if __name__ == '__main__': # pragma: no cover
  1456. main()
  1457. class _TestCononicalCoder(unittest.TestCase):
  1458. def test_con(self):
  1459. # make a dict
  1460. obja = {
  1461. 'foo': 23984732, 'a': 5, 'b': 6,
  1462. 'something': '2398472398723498273dfasdfjlaksdfj'
  1463. }
  1464. # reorder the items in it
  1465. objaitems = list(obja.items())
  1466. objaitems.sort()
  1467. objb = dict(objaitems)
  1468. # and they are still the same
  1469. self.assertEqual(obja, objb)
  1470. # This is to make sure that item order changed
  1471. self.assertNotEqual(list(obja.items()), list(objb.items()))
  1472. astr = pasn1.dumps(obja)
  1473. bstr = pasn1.dumps(objb)
  1474. # that they normally will be serialized differently
  1475. self.assertNotEqual(astr, bstr)
  1476. # but w/ the special encoder
  1477. astr = _asn1coder.dumps(obja)
  1478. bstr = _asn1coder.dumps(objb)
  1479. # they are now encoded the same
  1480. self.assertEqual(astr, bstr)
  1481. class _TestMigrations(unittest.TestCase):
  1482. def setUp(self):
  1483. self._engine = create_engine('sqlite+pysqlite:///:memory:',
  1484. echo=_sql_verbose, future=True)
  1485. def test_f2131(self):
  1486. # That an object store generated at the start
  1487. objstr = ObjectStore(self._engine, 'afad01589b76')
  1488. # and a host objects
  1489. hostobj = Host(created_by_ref=uuid.uuid4(), hostuuid=uuid.uuid4())
  1490. # build table metadata from original db
  1491. mdo = sqlalchemy.schema.MetaData()
  1492. mdobjstable = sqlalchemy.Table('metadata_objects', mdo, autoload_with=self._engine)
  1493. with objstr._ses() as session:
  1494. stmt = insert(mdobjstable).values(
  1495. uuid=hostobj.uuid.hex, modified=hostobj.modified,
  1496. data=hostobj.encode())
  1497. session.execute(stmt)
  1498. session.commit()
  1499. # migrate the database forward
  1500. objstr._handle_migration('head')
  1501. # make sure we can query it
  1502. self.assertEqual(list(objstr.get_hosts()), [ hostobj ])
  1503. self.assertEqual(list(objstr), [ hostobj ])
  1504. self.assertEqual(list(objstr.get_by_type('file')), [ ])
  1505. self.assertEqual(list(objstr.get_by_type(FileObject)), [ ])
  1506. self.assertEqual(list(objstr.get_by_type(Host)), [ hostobj ])
  1507. #with objstr._ses() as session:
  1508. # for i in session.query(orm.MetaDataObject).all():
  1509. # _debprint('c:', repr(i))
  1510. def test_dff0d(self):
  1511. # That an object store generated at the start
  1512. objstr = ObjectStore(self._engine, 'dff0d9ed0be1')
  1513. pers = Persona()
  1514. pers.generate_key()
  1515. objstr.loadobj(pers.get_identity())
  1516. obj = pers.MetaData({ 'other': 'baz'})
  1517. # That has a metadata object
  1518. objstr.loadobj(obj)
  1519. # migrate the database forward
  1520. objstr._handle_migration('head')
  1521. with objstr._ses() as session:
  1522. # that string table has entries
  1523. other = session.execute(select(orm.StringTable.id)
  1524. .where(orm.StringTable.str == 'other')).first()[0]
  1525. baz = session.execute(select(orm.StringTable.id)
  1526. .where(orm.StringTable.str == 'baz')).first()[0]
  1527. # that propertymapping was populated
  1528. pm = { (x.obj, x.keyid, x.valueid) for (x,) in
  1529. session.execute(select(orm.PropertyMapping)) }
  1530. self.assertEqual(pm, { (obj.uuid, other, baz) })
  1531. class _TestCases(unittest.TestCase):
  1532. def setUp(self):
  1533. self.fixtures = pathlib.Path('fixtures').resolve()
  1534. d = pathlib.Path(tempfile.mkdtemp()).resolve()
  1535. self.basetempdir = d
  1536. self.tempdir = d / 'subdir'
  1537. self.persona = Persona.load(os.path.join('fixtures',
  1538. 'sample.persona.pasn1'))
  1539. self.created_by_ref = self.persona.get_identity().uuid
  1540. shutil.copytree(self.fixtures / 'testfiles', self.tempdir)
  1541. shutil.copy(self.fixtures / 'sample.data.sqlite3', self.tempdir)
  1542. self.oldcwd = os.getcwd()
  1543. def tearDown(self):
  1544. shutil.rmtree(self.basetempdir)
  1545. self.tempdir = None
  1546. os.chdir(self.oldcwd)
  1547. def test_genstartstop(self):
  1548. self.assertEqual(genstartstop(5, 0), (0, 5))
  1549. self.assertEqual(genstartstop(5, 1), (0, 5))
  1550. self.assertEqual(genstartstop(5, 4), (0, 5))
  1551. self.assertEqual(genstartstop(25, 1), (0, 20))
  1552. self.assertEqual(genstartstop(25, 20), (5, 25))
  1553. self.assertEqual(genstartstop(25, 24), (5, 25))
  1554. self.assertEqual(genstartstop(124, 1), (0, 20))
  1555. self.assertEqual(genstartstop(124, 53), (43, 63))
  1556. self.assertEqual(genstartstop(124, 120), (104, 124))
  1557. self.assertEqual(genstartstop(124, 124), (104, 124))
  1558. def test_fileobject(self):
  1559. os.chdir(self.tempdir)
  1560. engine = create_engine(
  1561. "sqlite+pysqlite:///memdb1?mode=memory",
  1562. echo=_sql_verbose, future=True)
  1563. objst = ObjectStore(engine)
  1564. a = self.persona.by_file('test.txt')
  1565. # that the dir is absolute
  1566. self.assertEqual(a.dir[0], '/')
  1567. # make sure the file's hostid is a UUID
  1568. self.assertIsInstance(a.hostid, uuid.UUID)
  1569. # make sure the file's id is a UUID
  1570. self.assertIsInstance(a.id, uuid.UUID)
  1571. objst.loadobj(a)
  1572. #_debprint('a:', repr(a))
  1573. #_debprint('by_id:', objst.by_id(a.uuid))
  1574. # write out the store
  1575. objst.store('teststore.pasn1')
  1576. # load it back in
  1577. objstr = ObjectStore(engine)
  1578. a = objstr.by_id(a['uuid'])
  1579. # make sure the hostid is still a UUID
  1580. self.assertIsInstance(a.hostid, uuid.UUID)
  1581. # make sure the file's id is still a UUID
  1582. self.assertIsInstance(a.id, uuid.UUID)
  1583. # That it can be encoded to json
  1584. jsfo = a.encode('json')
  1585. # that it can be decoded from json
  1586. jsloadedfo = MDBase.decode(jsfo, 'json')
  1587. # and that it is equal
  1588. self.assertEqual(jsloadedfo, a)
  1589. def test_mdbase(self):
  1590. self.assertRaises(ValueError, MDBase, created_by_ref='')
  1591. self.assertRaises(ValueError, MDBase.create_obj,
  1592. { 'type': 'unknosldkfj' })
  1593. self.assertRaises(ValueError, MDBase.create_obj,
  1594. { 'type': 'metadata' })
  1595. baseobj = {
  1596. 'type': 'metadata',
  1597. 'created_by_ref': self.created_by_ref,
  1598. }
  1599. origbase = copy.deepcopy(baseobj)
  1600. # that when an MDBase object is created
  1601. md = MDBase.create_obj(baseobj)
  1602. # it doesn't modify the passed in object (when adding
  1603. # generated properties)
  1604. self.assertEqual(baseobj, origbase)
  1605. # and it has the generted properties
  1606. # Note: cannot mock the functions as they are already
  1607. # referenced at creation time
  1608. self.assertIn('uuid', md)
  1609. self.assertIn('modified', md)
  1610. # That you can create a new version using new_version
  1611. md2 = md.new_version(('dc:creator', 'Jim Bob',))
  1612. # that they are different
  1613. self.assertNotEqual(md, md2)
  1614. # and that the new modified time is different from the old
  1615. self.assertNotEqual(md.modified, md2.modified)
  1616. # and that the modification is present
  1617. self.assertEqual(md2['dc:creator'], [ 'Jim Bob' ])
  1618. # that providing a value from common property
  1619. fvalue = b'fakesig'
  1620. md3 = md.new_version(('sig', fvalue))
  1621. # gets set directly, and is not a list
  1622. self.assertEqual(md3.sig, fvalue)
  1623. # that invalid attribute access raises correct exception
  1624. self.assertRaises(AttributeError, getattr, md,
  1625. 'somerandombogusattribute')
  1626. # that when readding an attribute that already exists
  1627. md3 = md2.new_version(('dc:creator', 'Jim Bob',))
  1628. # that only one exists
  1629. self.assertEqual(md3['dc:creator'], [ 'Jim Bob' ])
  1630. def test_mdbase_encode_decode(self):
  1631. # that an object
  1632. baseobj = {
  1633. 'type': 'metadata',
  1634. 'created_by_ref': self.created_by_ref,
  1635. }
  1636. obj = MDBase.create_obj(baseobj)
  1637. # can be encoded
  1638. coded = obj.encode()
  1639. # and that the rsults can be decoded
  1640. decobj = MDBase.decode(coded)
  1641. # and that they are equal
  1642. self.assertEqual(obj, decobj)
  1643. # and in the encoded object
  1644. eobj = _asn1coder.loads(coded)
  1645. # the uuid property is a str instance
  1646. self.assertIsInstance(eobj['uuid'], bytes)
  1647. # and has the length of 16
  1648. self.assertEqual(len(eobj['uuid']), 16)
  1649. # and that json can be used to encode
  1650. js = obj.encode('json')
  1651. # and that it is valid json
  1652. jsobj = json.loads(js)
  1653. # and that it can be decoded
  1654. jsdecobj = MDBase.decode(js, 'json')
  1655. # and that it matches
  1656. self.assertEqual(jsdecobj, obj)
  1657. for key, inval in [
  1658. ('modified', '2022-08-19T01:27:34.258676'),
  1659. ('modified', '2022-08-19T01:27:34Z'),
  1660. ('modified', '2022-08-19T01:27:34.258676+00:00'),
  1661. ('uuid', 'z5336176-8086-4c21-984f-fda60ddaa172'),
  1662. ('uuid', '05336176-8086-421-984f-fda60ddaa172'),
  1663. ]:
  1664. jsobj['modified'] = inval
  1665. jstest = json.dumps(jsobj)
  1666. self.assertRaises(ValueError, MDBase.decode, jstest, 'json')
  1667. def test_mdbase_wrong_type(self):
  1668. # that created_by_ref can be passed by kw
  1669. obj = MetaData(created_by_ref=self.created_by_ref)
  1670. self.assertRaises(ValueError, FileObject, dict(obj.items(False)))
  1671. def test_makehash(self):
  1672. self.assertRaises(ValueError, ObjectStore.makehash, 'slkj')
  1673. self.assertRaises(ValueError, ObjectStore.makehash, 'sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ADA')
  1674. self.assertRaises(ValueError, ObjectStore.makehash, 'bogushash:9e0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ADA', strict=False)
  1675. self.assertEqual(ObjectStore.makehash('cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e', strict=False), 'sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e')
  1676. self.assertEqual(ObjectStore.makehash('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', strict=False), 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
  1677. @staticmethod
  1678. def statmock(fname):
  1679. fname = pathlib.Path(fname)
  1680. fnameparts = fname.parts
  1681. subdiridx = fnameparts.index('subdir')
  1682. _stats = {
  1683. # repr on os.stat_result doesn't work
  1684. # (mode, ino, dev, nlink, uid, gid, size, atime, mtime, ctime)
  1685. 'f': os.stat_result((stat.S_IROTH|stat.S_IXOTH|stat.S_IRGRP|stat.S_IXGRP|stat.S_IXUSR|stat.S_IRUSR|stat.S_IFDIR, 10, 100, 2, 100, 100, 1024, 1654166365, 1558388856.000000, 1663133775)),
  1686. 'test.txt': os.stat_result((stat.S_IROTH|stat.S_IRGRP|stat.S_IWUSR|stat.S_IRUSR|stat.S_IFREG, 10, 100, 1, 100, 100, 15, 1654166365, 1558388856.000000, 1663133775)),
  1687. 'newfile.txt': os.stat_result((stat.S_IROTH|stat.S_IRGRP|stat.S_IWUSR|stat.S_IRUSR|stat.S_IFREG, 10, 100, 1, 100, 100, 19, 1659652579, 1658982768.041291, 1663133775)),
  1688. 'sample.data.sqlite3': os.stat_result((stat.S_IROTH|stat.S_IRGRP|stat.S_IWUSR|stat.S_IRUSR|stat.S_IFREG, 10, 100, 1, 100, 100, 57344, 1663133777, 1663133777.529757, 1663133777)),
  1689. 't': os.stat_result((stat.S_IFDIR, 0, 0, 0, 0, 0, 0, 0, 0, 0)),
  1690. 'z.jpg': os.stat_result((stat.S_IROTH|stat.S_IRGRP|stat.S_IWUSR|stat.S_IRUSR|stat.S_IFREG, 10, 100, 1, 100, 100, 332, 1661553878, 1661551130.361235, 1663134325)),
  1691. }
  1692. subpath = '/'.join(fnameparts[subdiridx + 1:])
  1693. return _stats[subpath]
  1694. @mock.patch('os.stat')
  1695. def test_enumeratedir(self, statmock):
  1696. statmock.side_effect = self.statmock
  1697. files = enumeratedir(self.tempdir, self.created_by_ref)
  1698. ftest = [ x for x in files if x.filename == 'test.txt' ][0]
  1699. fname = 'test.txt'
  1700. # make sure that they are of type MDBase
  1701. self.assertIsInstance(ftest, MDBase)
  1702. oldid = ftest.id
  1703. self.assertEqual(ftest.filename, fname)
  1704. self.assertEqual(ftest.dir, str(self.tempdir))
  1705. # XXX - do we add host information?
  1706. self.assertEqual(ftest.id, uuid.uuid5(_NAMESPACE_MEDASHARE_PATH,
  1707. str(hostuuid()) + '/'.join(os.path.split(self.tempdir) +
  1708. ( fname, ))))
  1709. self.assertEqual(ftest.mtime, datetime.datetime(2019, 5, 20,
  1710. 21, 47, 36, tzinfo=datetime.timezone.utc))
  1711. self.assertEqual(ftest.size, 15)
  1712. self.assertIn('sha512:7d5768d47b6bc27dc4fa7e9732cfa2de506ca262a2749cb108923e5dddffde842bbfee6cb8d692fb43aca0f12946c521cce2633887914ca1f96898478d10ad3f', ftest.hashes)
  1713. # XXX - make sure works w/ relative dirs
  1714. files = enumeratedir(os.path.relpath(self.tempdir),
  1715. self.created_by_ref)
  1716. self.assertEqual(files[2].filename, 'test.txt')
  1717. self.assertEqual(oldid, files[2].id)
  1718. def test_mdbaseoverlay(self):
  1719. engine = create_engine("sqlite+pysqlite:///:memory:", echo=_sql_verbose, future=True)
  1720. objst = ObjectStore(engine)
  1721. # that a base object
  1722. bid = uuid.uuid4()
  1723. objst.loadobj({
  1724. 'type': 'metadata',
  1725. 'uuid': bid,
  1726. 'modified': datetime.datetime(2019, 6, 10, 14, 3, 10),
  1727. 'created_by_ref': self.created_by_ref,
  1728. 'hashes': [ 'sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada' ],
  1729. 'someprop': [ 'somevalue' ],
  1730. 'lang': 'en',
  1731. })
  1732. # can have an overlay object
  1733. oid = uuid.uuid4()
  1734. dhash = 'sha256:a7c96262c21db9a06fd49e307d694fd95f624569f9b35bb3ffacd880440f9787'
  1735. objst.loadobj({
  1736. 'type': 'metadata',
  1737. 'uuid': oid,
  1738. 'modified': datetime.datetime(2019, 6, 10, 18, 3, 10),
  1739. 'created_by_ref': self.created_by_ref,
  1740. 'hashes': [ dhash ],
  1741. 'parent_refs': [ bid ],
  1742. 'lang': 'en',
  1743. })
  1744. # and that when you get it's properties
  1745. oobj = objst.by_id(oid)
  1746. odict = dict(list(oobj.items()))
  1747. # that is has the overlays property
  1748. self.assertEqual(odict['parent_refs'], [ bid ])
  1749. # that it doesn't have a common property
  1750. self.assertNotIn('type', odict)
  1751. # that when skipcommon is False
  1752. odict = dict(oobj.items(False))
  1753. # that it does have a common property
  1754. self.assertIn('type', odict)
  1755. def test_cryptography_persona(self):
  1756. # Verify that a persona generated by cryptography still works
  1757. persona = Persona.load(self.fixtures / 'cryptography.persona.pasn1')
  1758. realpubkey = 'nFyLw6kB15DrM46ni9eEBRb6QD4rsPuco3ymj3mvz5YM8j3hY6chcjewU7FvqDpWALTSZ3E212SxCNErdYzPjgbxTnrYNyzeYTM2k58krEcKvWW6h'
  1759. pubkey = persona.get_pubkey()
  1760. self.assertEqual(realpubkey, pubkey)
  1761. vpersona = Persona.from_pubkey(realpubkey)
  1762. ident = persona.get_identity()
  1763. vpersona.verify(ident)
  1764. self.assertEqual(ident.uuid, uuid.UUID('52f1a92b-0c92-41e3-b647-356db89fb49c'))
  1765. def test_persona(self):
  1766. # that a newly created persona
  1767. persona = Persona()
  1768. # has an identity object
  1769. idobj = persona.get_identity()
  1770. # and that it has a uuid attribute that matches
  1771. self.assertEqual(persona.uuid, idobj['uuid'])
  1772. # that a key can be generated
  1773. persona.generate_key()
  1774. # that the pubkey property is present
  1775. idobj = persona.get_identity()
  1776. self.assertIsInstance(idobj['pubkey'], bytes)
  1777. # that get_pubkey returns the correct thing
  1778. pubstr = _asn1coder.dumps([ idobj.uuid, idobj['pubkey'] ])
  1779. self.assertEqual(persona.get_pubkey(),
  1780. base58.b58encode_check(pubstr).decode('ascii'))
  1781. # and that there is a signature
  1782. self.assertIsInstance(idobj['sig'], bytes)
  1783. # and that it can verify itself
  1784. persona.verify(idobj)
  1785. # and that a new persona can be created from the pubkey
  1786. pkpersona = Persona.from_pubkey(persona.get_pubkey())
  1787. # and that it can verify the old identity
  1788. self.assertTrue(pkpersona.verify(idobj))
  1789. # that a second time, it raises an exception
  1790. self.assertRaises(RuntimeError, persona.generate_key)
  1791. # that a file object created by it
  1792. testfname = os.path.join(self.tempdir, 'test.txt')
  1793. testobj = persona.by_file(testfname)
  1794. # has the correct created_by_ref
  1795. self.assertEqual(testobj.created_by_ref, idobj.uuid)
  1796. self.assertEqual(testobj.type, 'file')
  1797. # and has a signature
  1798. self.assertIn('sig', testobj)
  1799. # that a persona created from the identity object
  1800. vpersona = Persona(idobj)
  1801. # can verify the sig
  1802. self.assertTrue(vpersona.verify(testobj))
  1803. # and that a bogus signature
  1804. bogussig = 'somebogussig'
  1805. bogusobj = MDBase.create_obj(testobj)
  1806. bogusobj.sig = bogussig
  1807. # fails to verify
  1808. self.assertRaises(Exception, vpersona.verify, bogusobj)
  1809. # and that a modified object
  1810. otherobj = testobj.new_version(('customprop', 'value'))
  1811. # fails to verify
  1812. self.assertRaises(Exception, vpersona.verify, otherobj)
  1813. # that a persona object can be written
  1814. perpath = os.path.join(self.basetempdir, 'persona.pasn1')
  1815. persona.store(perpath)
  1816. # and that when loaded back
  1817. loadpersona = Persona.load(perpath)
  1818. # the new persona object can sign an object
  1819. nvtestobj = loadpersona.sign(testobj.new_version())
  1820. # and the old persona can verify it.
  1821. self.assertTrue(vpersona.verify(nvtestobj))
  1822. def test_persona_metadata(self):
  1823. # that a persona
  1824. persona = Persona()
  1825. persona.generate_key()
  1826. # can create a metadata object
  1827. hashobj = ['asdlfkj']
  1828. mdobj = persona.MetaData(hashes=hashobj)
  1829. # that the object has the correct created_by_ref
  1830. self.assertEqual(mdobj.created_by_ref, persona.uuid)
  1831. # and has the provided hashes
  1832. self.assertEqual(mdobj.hashes, hashobj)
  1833. # and that it can be verified
  1834. persona.verify(mdobj)
  1835. def test_objectstore(self):
  1836. persona = self.persona
  1837. objst = ObjectStore.load(self.tempdir / 'sample.data.sqlite3')
  1838. lst = objst.by_hash('91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada')
  1839. self.assertEqual(len(lst), 1)
  1840. objst.loadobj({
  1841. 'type': 'metadata',
  1842. 'uuid': uuid.UUID('c9a1d1e2-3109-4efd-8948-577dc15e44e7'),
  1843. 'modified': datetime.datetime(2019, 5, 31, 14, 3, 10,
  1844. tzinfo=datetime.timezone.utc),
  1845. 'created_by_ref': self.created_by_ref,
  1846. 'hashes': [ 'sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada' ],
  1847. 'lang': 'en',
  1848. })
  1849. lst = objst.by_hash('91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada')
  1850. self.assertEqual(len(lst), 2)
  1851. byid = objst.by_id('3e466e06-45de-4ecc-84ba-2d2a3d970e96')
  1852. self.assertIsInstance(byid, MetaData)
  1853. self.assertIn(byid, lst)
  1854. r = byid
  1855. self.assertEqual(r.uuid, uuid.UUID('3e466e06-45de-4ecc-84ba-2d2a3d970e96'))
  1856. self.assertEqual(r['dc:creator'], [ 'John-Mark Gurney' ])
  1857. # XXX do we care anymore?
  1858. if False:
  1859. # test storing the object store
  1860. fname = 'testfile.sqlite3'
  1861. objst.store(fname)
  1862. with open(fname, 'rb') as fp:
  1863. objs = _asn1coder.loads(fp.read())
  1864. os.unlink(fname)
  1865. self.assertEqual(len(objs), len(objst))
  1866. self.assertEqual(objs['created_by_ref'], self.created_by_ref.bytes)
  1867. # make sure that the read back data matches
  1868. for i in objs['objects']:
  1869. i['created_by_ref'] = uuid.UUID(bytes=i['created_by_ref'])
  1870. i['uuid'] = uuid.UUID(bytes=i['uuid'])
  1871. self.assertEqual(objst.by_id(i['uuid']), i)
  1872. # that a file
  1873. testfname = os.path.join(self.tempdir, 'test.txt')
  1874. # when registered
  1875. objst.loadobj(persona.by_file(testfname))
  1876. # can be found
  1877. self.assertEqual(objst.by_file(testfname), [ byid ])
  1878. self.assertRaises(KeyError, objst.by_file, '/dev/null')
  1879. # that when a metadata object
  1880. mdouuid = 'c9a1d1e2-3109-4efd-8948-577dc15e44e7'
  1881. origobj = objst.by_id(mdouuid)
  1882. # is updated:
  1883. obj = origobj.new_version(('foo', 'bar'))
  1884. # and stored
  1885. objst.loadobj(obj)
  1886. # that it is the new one
  1887. self.assertEqual(obj, objst.by_id(mdouuid))
  1888. # and that the old one isn't present anymore in by file
  1889. lst = objst.by_hash('91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada')
  1890. self.assertNotIn(origobj, lst)
  1891. # XXX make sure that object store contains fileobject
  1892. # Tests to add:
  1893. # Non-duplicates when same metadata is located by multiple hashes.
  1894. def objcompare(self, fullobjs, partialobjs):
  1895. fullobjs = list(fullobjs)
  1896. #_debprint('objs:', repr(fullobjs), repr(partialobjs))
  1897. self.assertEqual(len(fullobjs), len(partialobjs))
  1898. missing = []
  1899. for i in partialobjs:
  1900. for idx, j in enumerate(fullobjs):
  1901. cmpobj = dict((k, v) for k, v in j.items() if k in set(i.keys()))
  1902. if cmpobj == i:
  1903. break
  1904. else: # pragma: no cover
  1905. missing.append(i)
  1906. continue
  1907. fullobjs.pop(idx)
  1908. if missing: # pragma: no cover
  1909. _debprint('remaining objs:', repr(fullobjs))
  1910. self.fail('Unable to find objects %s in dump' % missing)
  1911. def save_db(f): # pragma: no cover
  1912. @functools.wraps(f)
  1913. def wrapper(self, *args, **kwargs):
  1914. try:
  1915. return f(self, *args, **kwargs)
  1916. except:
  1917. shutil.copyfile(self.storefname,
  1918. '/tmp/failure.sqlite3')
  1919. raise
  1920. return wrapper
  1921. #@save_db
  1922. def run_command_file(self, f):
  1923. with open(f) as fp:
  1924. cmds = json.load(fp)
  1925. # setup object store
  1926. storefname = self.tempdir / 'storefname'
  1927. self.storefname = storefname
  1928. identfname = self.tempdir / 'identfname'
  1929. cachefname = self.tempdir / 'cachefname'
  1930. # setup path mapping
  1931. def expandusermock(arg):
  1932. if arg == '~/.medashare_store.sqlite3':
  1933. return storefname
  1934. elif arg == '~/.medashare_identity.pasn1':
  1935. return identfname
  1936. elif arg == '~/.medashare_cache.pasn1':
  1937. return cachefname
  1938. if True: #pragma: no cover
  1939. raise NotImplementedError(arg)
  1940. # setup test fname
  1941. testfname = os.path.join(self.tempdir, 'test.txt')
  1942. newtestfname = os.path.join(self.tempdir, 'newfile.txt')
  1943. patches = []
  1944. for idx, cmd in enumerate(cmds):
  1945. try:
  1946. if cmd['skip']: # pragma: no cover
  1947. continue
  1948. except KeyError:
  1949. pass
  1950. for i in cmd.get('format', []):
  1951. if i in { 'cmd', 'files' }:
  1952. vars = locals()
  1953. cmd[i] = [ x.format(**vars) for x in cmd[i] ]
  1954. else:
  1955. cmd[i] = cmd[i].format(**locals())
  1956. try:
  1957. special = cmd['special']
  1958. except KeyError:
  1959. pass
  1960. else:
  1961. if special == 'copy newfile.txt to test.txt':
  1962. shutil.copy(newtestfname, testfname)
  1963. elif special == 'change newfile.txt':
  1964. with open(newtestfname, 'w') as fp:
  1965. fp.write('some new contents')
  1966. elif special == 'verify store object cnt':
  1967. objst = ObjectStore.load(storefname)
  1968. objcnt = len(objst)
  1969. self.assertEqual(objcnt, len(list(objst)))
  1970. self.assertEqual(objcnt, cmd['count'])
  1971. elif special == 'set hostid':
  1972. hostidpatch = mock.patch(__name__ + '.hostuuid')
  1973. hid = uuid.UUID(cmd['hostid']) if 'hostid' in cmd else uuid.uuid4()
  1974. hostidpatch.start().return_value = hid
  1975. patches.append(hostidpatch)
  1976. elif special == 'iter is unique':
  1977. objst = ObjectStore.load(storefname)
  1978. uniqobjs = len(set((x['uuid'] for x in objst)))
  1979. self.assertEqual(len(list(objst)), uniqobjs)
  1980. elif special == 'setup bittorrent files':
  1981. # copy in the torrent file
  1982. tor = importlib.resources.files('medashare.btv')
  1983. tor = tor / 'fixtures' / 'somedir.torrent'
  1984. shutil.copy(tor, self.tempdir)
  1985. # partly recreate files
  1986. btfiles = bttestcase.origfiledata.copy()
  1987. if not cmd['complete']:
  1988. btfiles.update(bttestcase.badfiles)
  1989. sd = self.tempdir / bttestcase.dirname
  1990. sd.mkdir(exist_ok=True)
  1991. bttestcase.make_files(sd, btfiles)
  1992. elif special == 'setup mapping paths':
  1993. mappatha = self.tempdir / 'mapa'
  1994. mappatha.mkdir()
  1995. mappathb = self.tempdir / 'mapb'
  1996. mappathb.mkdir()
  1997. filea = mappatha / 'text.txt'
  1998. filea.write_text('abc123\n')
  1999. fileb = mappathb / 'text.txt'
  2000. shutil.copyfile(filea, fileb)
  2001. shutil.copystat(filea, fileb)
  2002. elif special == 'delete files':
  2003. for i in cmd['files']:
  2004. os.unlink(i)
  2005. elif special == 'setup file':
  2006. shutil.copy(self.fixtures /
  2007. cmd['file'], self.tempdir)
  2008. elif special == 'setup tar file':
  2009. shutil.copy(self.fixtures /
  2010. 'testfile.tar.gz', self.tempdir)
  2011. elif special == 'store now':
  2012. storednow = datetime.datetime.now(datetime.timezone.utc)
  2013. elif special == 'format next cmd':
  2014. modcmd = cmds[idx + 1]
  2015. formatkwargs = dict(nowiso=storednow.isoformat())
  2016. modcmd['cmd'] = [ x.format(**formatkwargs) for x in modcmd['cmd'] ]
  2017. else: # pragma: no cover
  2018. raise ValueError('unhandled special: %s' % repr(special))
  2019. # coverage bug, fixed in 3.10:
  2020. # https://github.com/nedbat/coveragepy/issues/1432#event-7130600158
  2021. if True: # pragma: no cover
  2022. continue
  2023. with self.subTest(file=f, title=cmd['title']), \
  2024. mock.patch('os.path.expanduser',
  2025. side_effect=expandusermock) as eu, \
  2026. mock.patch('sys.stdin', io.StringIO()) as stdin, \
  2027. mock.patch('sys.stdout', io.StringIO()) as stdout, \
  2028. mock.patch('sys.stderr', io.StringIO()) as stderr, \
  2029. mock.patch('sys.argv', [ 'progname', ] +
  2030. cmd['cmd']) as argv:
  2031. # if there is stdin
  2032. test_stdin = cmd.get('stdin', '')
  2033. # provide it
  2034. stdin.write(test_stdin)
  2035. stdin.seek(0)
  2036. with self.assertRaises(SystemExit) as cm:
  2037. try:
  2038. main()
  2039. except AssertionError: # pragma: no cover
  2040. # used to nab a copy of the
  2041. # failed sqlite3 db
  2042. if False:
  2043. shutil.copyfile(
  2044. storefname,
  2045. '/tmp/failure.sqlite3')
  2046. raise
  2047. # XXX - Minor hack till other tests fixed
  2048. sys.exit(0)
  2049. # with the correct output
  2050. self.maxDiff = None
  2051. outeq = cmd.get('stdout')
  2052. outnre = cmd.get('stdout_nre')
  2053. outre = cmd.get('stdout_re')
  2054. outcheck = cmd.get('stdout_check')
  2055. # python3 -c 'import ast, sys; print(ast.literal_eval(sys.stdin.read()))' << EOF | jq '.'
  2056. if outnre:
  2057. self.assertNotRegex(stdout.getvalue(), outnre)
  2058. if outre:
  2059. self.assertRegex(stdout.getvalue(), outre)
  2060. if outeq:
  2061. self.assertEqual(stdout.getvalue(), outeq)
  2062. if outcheck:
  2063. stdout.seek(0)
  2064. self.objcompare(_json_objstream(stdout), outcheck)
  2065. self.assertEqual(stderr.getvalue(), cmd.get('stderr', ''))
  2066. self.assertEqual(cm.exception.code, cmd.get('exit', 0))
  2067. patches.reverse()
  2068. for i in patches:
  2069. i.stop()
  2070. def test_get_paths(self):
  2071. # Test to make sure get paths works as expected.
  2072. with mock.patch('os.path.expanduser') as eu:
  2073. a, b, c = _get_paths(None)
  2074. eu.assert_any_call('~/.medashare_identity.pasn1')
  2075. eu.assert_any_call('~/.medashare_store.sqlite3')
  2076. eu.assert_any_call('~/.medashare_cache.pasn1')
  2077. pathpref = pathlib.Path('/somepath/somewhere')
  2078. with mock.patch.dict(os.environ, dict(MEDASHARE_PATH=str(pathpref))):
  2079. i, s, c = _get_paths(None)
  2080. self.assertEqual(i, str(pathpref / '.medashare_identity.pasn1'))
  2081. self.assertEqual(s, str(pathpref / '.medashare_store.sqlite3'))
  2082. self.assertEqual(c, str(pathpref / '.medashare_cache.pasn1'))
  2083. def test_help(self):
  2084. # that subcommand help is the same as --help
  2085. with mock.patch('sys.stdout', io.StringIO()) as stdout, \
  2086. mock.patch('sys.argv', [ 'progname', '--help', ]) as argv:
  2087. with self.assertRaises(SystemExit) as cm:
  2088. main()
  2089. # XXX - Minor hack till other tests fixed
  2090. sys.exit(0)
  2091. dashhelp = stdout.getvalue()
  2092. with mock.patch('sys.stdout', io.StringIO()) as stdout, \
  2093. mock.patch('sys.argv', [ 'progname', 'help', ]) as argv:
  2094. with self.assertRaises(SystemExit) as cm:
  2095. main()
  2096. # XXX - Minor hack till other tests fixed
  2097. sys.exit(0)
  2098. subhelp = stdout.getvalue()
  2099. self.assertEqual(dashhelp, subhelp)
  2100. with mock.patch('sys.stdout', io.StringIO()) as stdout, \
  2101. mock.patch('sys.argv', [ 'progname', ]) as argv:
  2102. with self.assertRaises(SystemExit) as cm:
  2103. main()
  2104. # XXX - Minor hack till other tests fixed
  2105. sys.exit(0)
  2106. subhelp = stdout.getvalue()
  2107. self.assertEqual(dashhelp, subhelp)
  2108. #@unittest.skip('temp')
  2109. def test_cmds(self):
  2110. cmds = sorted(self.fixtures.glob('cmd.*.json'))
  2111. for i in cmds:
  2112. # make sure each file starts with a clean slate
  2113. self.tearDown()
  2114. self.setUp()
  2115. os.chdir(self.tempdir)
  2116. with self.subTest(file=i):
  2117. self.run_command_file(i)
  2118. # XXX - the following test may no longer be needed
  2119. def test_main(self):
  2120. # Test the main runner, this is only testing things that are
  2121. # specific to running the program, like where the store is
  2122. # created.
  2123. # setup object store
  2124. storefname = self.tempdir / 'storefname'
  2125. identfname = self.tempdir / 'identfname'
  2126. cachefname = self.tempdir / 'cachefname'
  2127. # setup path mapping
  2128. def expandusermock(arg):
  2129. if arg == '~/.medashare_store.sqlite3':
  2130. return storefname
  2131. elif arg == '~/.medashare_identity.pasn1':
  2132. return identfname
  2133. elif arg == '~/.medashare_cache.pasn1':
  2134. return cachefname
  2135. # setup test fname
  2136. testfname = os.path.join(self.tempdir, 'test.txt')
  2137. newtestfname = os.path.join(self.tempdir, 'newfile.txt')
  2138. import itertools
  2139. with mock.patch('os.path.expanduser', side_effect=expandusermock) \
  2140. as eu, mock.patch('medashare.cli.open') as op:
  2141. # that when opening the store and identity fails
  2142. op.side_effect = FileNotFoundError
  2143. # and there is no identity
  2144. with mock.patch('sys.stderr', io.StringIO()) as stderr, mock.patch('sys.argv', [ 'progname', 'list', 'afile' ]) as argv:
  2145. with self.assertRaises(SystemExit) as cm:
  2146. main()
  2147. # that it fails
  2148. self.assertEqual(cm.exception.code, 1)
  2149. # with the correct error message
  2150. self.assertEqual(stderr.getvalue(),
  2151. 'ERROR: Identity not created, create w/ genident.\n')
  2152. with mock.patch('os.path.expanduser', side_effect=expandusermock) \
  2153. as eu:
  2154. # that generating a new identity
  2155. with mock.patch('sys.stdout', io.StringIO()) as stdout, mock.patch('sys.argv', [ 'progname', 'genident', 'name=A Test User' ]) as argv:
  2156. main()
  2157. # does not output anything
  2158. self.assertEqual(stdout.getvalue(), '')
  2159. # looks up the correct file
  2160. eu.assert_any_call('~/.medashare_identity.pasn1')
  2161. eu.assert_any_call('~/.medashare_store.sqlite3')
  2162. eu.assert_any_call('~/.medashare_cache.pasn1')
  2163. # and that the identity
  2164. persona = Persona.load(identfname)
  2165. pident = persona.get_identity()
  2166. # has the correct name
  2167. self.assertEqual(pident.name, 'A Test User')
  2168. # that when generating an identity when one already exists
  2169. with mock.patch('sys.stderr', io.StringIO()) as stderr, mock.patch('sys.argv', [ 'progname', 'genident', 'name=A Test User' ]) as argv:
  2170. # that it exits
  2171. with self.assertRaises(SystemExit) as cm:
  2172. main()
  2173. # with error code 1
  2174. self.assertEqual(cm.exception.code, 1)
  2175. # and outputs an error message
  2176. self.assertEqual(stderr.getvalue(),
  2177. 'Error: Identity already created.\n')
  2178. # and looked up the correct file
  2179. eu.assert_any_call('~/.medashare_identity.pasn1')
  2180. # that when updating the identity
  2181. with mock.patch('sys.stdout', io.StringIO()) as stdout, mock.patch('sys.argv', [ 'progname', 'ident', 'name=Changed Name' ]) as argv:
  2182. main()
  2183. # it doesn't output anything
  2184. self.assertEqual(stdout.getvalue(), '')
  2185. # and looked up the correct file
  2186. eu.assert_any_call('~/.medashare_identity.pasn1')
  2187. npersona = Persona.load(identfname)
  2188. nident = npersona.get_identity()
  2189. # and has the new name
  2190. self.assertEqual(nident.name, 'Changed Name')
  2191. # and has the same old uuid
  2192. self.assertEqual(nident.uuid, pident.uuid)
  2193. # and that the modified date has changed
  2194. self.assertNotEqual(pident.modified, nident.modified)
  2195. # and that the old Persona can verify the new one
  2196. self.assertTrue(persona.verify(nident))
  2197. orig_open = open
  2198. with mock.patch('os.path.expanduser', side_effect=expandusermock) \
  2199. as eu, mock.patch('medashare.cli.open') as op:
  2200. # that when the store fails
  2201. def open_repl(fname, mode):
  2202. #print('or:', repr(fname), repr(mode), file=sys.stderr)
  2203. self.assertIn(mode, ('rb', 'wb'))
  2204. if fname == identfname or mode == 'wb':
  2205. return orig_open(fname, mode)
  2206. #print('foo:', repr(fname), repr(mode), file=sys.stderr)
  2207. if True: #pragma: no cover
  2208. raise FileNotFoundError
  2209. op.side_effect = open_repl
  2210. # and there is no store
  2211. with mock.patch('sys.stderr', io.StringIO()) as stderr, mock.patch('sys.argv', [ 'progname', 'list', 'foo', ]) as argv:
  2212. # that it exits
  2213. with self.assertRaises(SystemExit) as cm:
  2214. main()
  2215. # with error code 1
  2216. self.assertEqual(cm.exception.code, 1)
  2217. # and outputs an error message
  2218. self.assertEqual(stderr.getvalue(),
  2219. 'ERROR: file not found: \'foo\'\n')
  2220. # Tests to add:
  2221. # that cache is updated on modify cmd
  2222. # add tests for dump options uuid and hash
  2223. # expand mappings to multiple mappings, that is a -> b, b -> c, implies a -> c
  2224. # support host names in --create