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.
 
 
 
 

2356 lines
63 KiB

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