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.
 
 
 
 

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