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.
 
 
 
 

1282 lines
34 KiB

  1. #!/usr/bin/env python
  2. #import pdb, sys; mypdb = pdb.Pdb(stdout=sys.stderr); mypdb.set_trace()
  3. from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey, \
  4. Ed448PublicKey
  5. from cryptography.hazmat.primitives.serialization import Encoding, \
  6. PrivateFormat, PublicFormat, NoEncryption
  7. from unittest import mock
  8. import base58
  9. import copy
  10. import datetime
  11. import functools
  12. import hashlib
  13. import io
  14. import json
  15. import os.path
  16. import pathlib
  17. import pasn1
  18. import re
  19. import shutil
  20. import string
  21. import sys
  22. import tempfile
  23. import unittest
  24. import uuid
  25. # The UUID for the namespace representing the path to a file
  26. _NAMESPACE_MEDASHARE_PATH = uuid.UUID('f6f36b62-3770-4a68-bc3d-dc3e31e429e6')
  27. _defaulthash = 'sha512'
  28. _validhashes = set([ 'sha256', 'sha512' ])
  29. _hashlengths = { len(getattr(hashlib, x)().hexdigest()): x for x in
  30. _validhashes }
  31. def _keyordering(x):
  32. k, v = x
  33. try:
  34. return (MDBase._common_names_list.index(k), k, v)
  35. except ValueError:
  36. return (2**32, k, v)
  37. def _iterdictlist(obj, **kwargs):
  38. l = list(sorted(obj.items(**kwargs), key=_keyordering))
  39. for k, v in l:
  40. if isinstance(v, list):
  41. for i in sorted(v):
  42. yield k, i
  43. else:
  44. yield k, v
  45. def _makeuuid(s):
  46. if isinstance(s, uuid.UUID):
  47. return s
  48. return uuid.UUID(bytes=s)
  49. # XXX - known issue, store is not atomic/safe, overwrites in place instead of
  50. # renames
  51. # XXX - add validation
  52. # XXX - how to add singletons
  53. class MDBase(object):
  54. '''This is a simple wrapper that turns a JSON object into a pythonesc
  55. object where attribute accesses work.'''
  56. _type = 'invalid'
  57. _generated_properties = {
  58. 'uuid': uuid.uuid4,
  59. 'modified': datetime.datetime.utcnow
  60. }
  61. # When decoding, the decoded value should be passed to this function
  62. # to get the correct type
  63. _instance_properties = {
  64. 'uuid': _makeuuid,
  65. 'created_by_ref': _makeuuid,
  66. #'parent_refs': lambda x: [ _makeuuid(y) for y in x ],
  67. }
  68. _common_properties = [ 'type', 'created_by_ref' ] # XXX - add lang?
  69. _common_optional = set(('parent_refs', 'sig'))
  70. _common_names = set(_common_properties + list(
  71. _generated_properties.keys()))
  72. _common_names_list = _common_properties + list(
  73. _generated_properties.keys())
  74. def __init__(self, obj={}, **kwargs):
  75. obj = copy.deepcopy(obj)
  76. obj.update(kwargs)
  77. if self._type == MDBase._type:
  78. raise ValueError('call MDBase.create_obj instead so correct class is used.')
  79. if 'type' in obj and obj['type'] != self._type:
  80. raise ValueError(
  81. 'trying to create the wrong type of object, got: %s, expected: %s' %
  82. (repr(obj['type']), repr(self._type)))
  83. if 'type' not in obj:
  84. obj['type'] = self._type
  85. for x in self._common_properties:
  86. if x not in obj:
  87. raise ValueError('common property %s not present' % repr(x))
  88. for x, fun in self._instance_properties.items():
  89. if x in obj:
  90. obj[x] = fun(obj[x])
  91. for x, fun in self._generated_properties.items():
  92. if x not in obj:
  93. obj[x] = fun()
  94. self._obj = obj
  95. @classmethod
  96. def create_obj(cls, obj):
  97. '''Using obj as a base, create an instance of MDBase of the
  98. correct type.
  99. If the correct type is not found, a ValueError is raised.'''
  100. if isinstance(obj, cls):
  101. obj = obj._obj
  102. ty = obj['type']
  103. for i in MDBase.__subclasses__():
  104. if i._type == ty:
  105. return i(obj)
  106. else:
  107. raise ValueError('Unable to find class for type %s' %
  108. repr(ty))
  109. def new_version(self, *args):
  110. '''For each k, v pair, add the property k as an additional one
  111. (or new one if first), with the value v.'''
  112. obj = copy.deepcopy(self._obj)
  113. common = self._common_names | self._common_optional
  114. for k, v in args:
  115. if k in common:
  116. obj[k] = v
  117. else:
  118. obj.setdefault(k, []).append(v)
  119. del obj['modified']
  120. return self.create_obj(obj)
  121. def __repr__(self): # pragma: no cover
  122. return '%s(%s)' % (self.__class__.__name__, repr(self._obj))
  123. def __getattr__(self, k):
  124. try:
  125. return self._obj[k]
  126. except KeyError:
  127. raise AttributeError(k)
  128. def __setattr__(self, k, v):
  129. if k[0] == '_': # direct attribute
  130. self.__dict__[k] = v
  131. else:
  132. self._obj[k] = v
  133. def __getitem__(self, k):
  134. return self._obj[k]
  135. def __to_dict__(self):
  136. return self._obj
  137. def __eq__(self, o):
  138. return self._obj == o
  139. def __contains__(self, k):
  140. return k in self._obj
  141. def items(self, skipcommon=True):
  142. return [ (k, v) for k, v in self._obj.items() if
  143. not skipcommon or k not in self._common_names ]
  144. def encode(self):
  145. return _asn1coder.dumps(self)
  146. @classmethod
  147. def decode(cls, s):
  148. return cls.create_obj(_asn1coder.loads(s))
  149. class MetaData(MDBase):
  150. _type = 'metadata'
  151. class Identity(MDBase):
  152. _type = 'identity'
  153. # Identites don't need a created by
  154. _common_properties = [ x for x in MDBase._common_properties if x !=
  155. 'created_by_ref' ]
  156. _common_optional = set([ x for x in MDBase._common_optional if x !=
  157. 'parent_refs' ] + [ 'name', 'pubkey' ])
  158. _common_names = set(_common_properties + list(
  159. MDBase._generated_properties.keys()))
  160. def _trytodict(o):
  161. if isinstance(o, uuid.UUID):
  162. return 'bytes', o.bytes
  163. try:
  164. return 'dict', o.__to_dict__()
  165. except Exception: # pragma: no cover
  166. raise TypeError('unable to find __to_dict__ on %s: %s' %
  167. (type(o), repr(o)))
  168. class CanonicalCoder(pasn1.ASN1DictCoder):
  169. def enc_dict(self, obj, **kwargs):
  170. class FakeIter:
  171. def items(self):
  172. return iter(sorted(obj.items()))
  173. return pasn1.ASN1DictCoder.enc_dict(self, FakeIter(), **kwargs)
  174. _asn1coder = CanonicalCoder(coerce=_trytodict)
  175. class Persona(object):
  176. '''The object that represents a persona, or identity. It will
  177. create the proper identity object, serialize for saving keys,
  178. create objects for that persona and other management.'''
  179. def __init__(self, identity=None, key=None):
  180. if identity is None:
  181. self._identity = Identity()
  182. else:
  183. self._identity = identity
  184. self._key = key
  185. self._pubkey = None
  186. if 'pubkey' in self._identity:
  187. pubkeybytes = self._identity.pubkey
  188. self._pubkey = Ed448PublicKey.from_public_bytes(
  189. pubkeybytes)
  190. self._created_by_ref = self._identity.uuid
  191. def MetaData(self, *args, **kwargs):
  192. kwargs['created_by_ref'] = self.uuid
  193. return self.sign(MetaData(*args, **kwargs))
  194. @property
  195. def uuid(self):
  196. '''Return the UUID of the identity represented.'''
  197. return self._identity.uuid
  198. def __repr__(self): # pragma: no cover
  199. r = '<Persona: has key: %s, has pubkey: %s, identity: %s>' % \
  200. (self._key is not None, self._pubkey is not None,
  201. repr(self._identity))
  202. return r
  203. @classmethod
  204. def from_pubkey(cls, pubkeystr):
  205. pubstr = base58.b58decode_check(pubkeystr)
  206. uuid, pubkey = _asn1coder.loads(pubstr)
  207. ident = Identity(uuid=uuid, pubkey=pubkey)
  208. return cls(ident)
  209. def get_identity(self):
  210. '''Return the Identity object for this Persona.'''
  211. return self._identity
  212. def get_pubkey(self):
  213. '''Get a printable version of the public key. This is used
  214. for importing into different programs, or for shared.'''
  215. idobj = self._identity
  216. pubstr = _asn1coder.dumps([ idobj.uuid, idobj.pubkey ])
  217. return base58.b58encode_check(pubstr)
  218. def new_version(self, *args):
  219. '''Update the Persona's Identity object.'''
  220. self._identity = self.sign(self._identity.new_version(*args))
  221. return self._identity
  222. def store(self, fname):
  223. '''Store the Persona to a file. If there is a private
  224. key associated w/ the Persona, it will be saved as well.'''
  225. with open(fname, 'wb') as fp:
  226. obj = {
  227. 'identity': self._identity,
  228. }
  229. if self._key is not None:
  230. obj['key'] = \
  231. self._key.private_bytes(Encoding.Raw,
  232. PrivateFormat.Raw, NoEncryption())
  233. fp.write(_asn1coder.dumps(obj))
  234. @classmethod
  235. def load(cls, fname):
  236. '''Load the Persona from the provided file.'''
  237. with open(fname, 'rb') as fp:
  238. objs = _asn1coder.loads(fp.read())
  239. kwargs = {}
  240. if 'key' in objs:
  241. kwargs['key'] = Ed448PrivateKey.from_private_bytes(
  242. objs['key'])
  243. return cls(Identity(objs['identity']), **kwargs)
  244. def generate_key(self):
  245. '''Generate a key for this Identity.
  246. Raises a RuntimeError if a key is already present.'''
  247. if self._key:
  248. raise RuntimeError('a key already exists')
  249. self._key = Ed448PrivateKey.generate()
  250. self._pubkey = self._key.public_key()
  251. pubkey = self._pubkey.public_bytes(Encoding.Raw,
  252. PublicFormat.Raw)
  253. self._identity = self.sign(self._identity.new_version(('pubkey',
  254. pubkey)))
  255. def _makesigbytes(self, obj):
  256. obj = dict(obj.items(False))
  257. try:
  258. del obj['sig']
  259. except KeyError:
  260. pass
  261. return _asn1coder.dumps(obj)
  262. def sign(self, obj):
  263. '''Takes the object, adds a signature, and returns the new
  264. object.'''
  265. sigbytes = self._makesigbytes(obj)
  266. sig = self._key.sign(sigbytes)
  267. newobj = MDBase.create_obj(obj)
  268. newobj.sig = sig
  269. return newobj
  270. def verify(self, obj):
  271. sigbytes = self._makesigbytes(obj)
  272. pubkey = self._pubkey.public_bytes(Encoding.Raw,
  273. PublicFormat.Raw)
  274. self._pubkey.verify(obj['sig'], sigbytes)
  275. return True
  276. def by_file(self, fname):
  277. '''Return a metadata object for the file named fname.'''
  278. fobj = FileObject.from_file(fname, self._created_by_ref)
  279. return self.sign(fobj)
  280. class ObjectStore(object):
  281. '''A container to store for the various Metadata objects.'''
  282. # The _uuids property contains both the UUIDv4 for objects, and
  283. # looking up the UUIDv5 for FileObjects.
  284. def __init__(self, created_by_ref):
  285. self._created_by_ref = created_by_ref
  286. self._uuids = {}
  287. self._hashes = {}
  288. @staticmethod
  289. def makehash(hashstr, strict=True):
  290. '''Take a hash or hash string, and return a valid hash
  291. string from it.
  292. This makes sure that it is of the correct type and length.
  293. If strict is False, the function will detect the length and
  294. return a valid hash string if one can be found.
  295. By default, the string must be prepended by the type,
  296. followed by a colon, followed by the value in hex in all
  297. lower case characters.'''
  298. try:
  299. hash, value = hashstr.split(':')
  300. except ValueError:
  301. if strict:
  302. raise
  303. hash = _hashlengths[len(hashstr)]
  304. value = hashstr
  305. bvalue = value.encode('ascii')
  306. if strict and len(bvalue.translate(None,
  307. string.hexdigits.lower().encode('ascii'))) != 0:
  308. raise ValueError('value has invalid hex digits (must be lower case)', value)
  309. if hash in _validhashes:
  310. return ':'.join((hash, value))
  311. raise ValueError
  312. def __len__(self):
  313. return len(self._uuids)
  314. def store(self, fname):
  315. '''Write out the objects in the store to the file named
  316. fname.'''
  317. with open(fname, 'wb') as fp:
  318. obj = {
  319. 'created_by_ref': self._created_by_ref,
  320. 'objects': list(self._uuids.values()),
  321. }
  322. fp.write(_asn1coder.dumps(obj))
  323. def loadobj(self, obj):
  324. '''Load obj into the data store.'''
  325. obj = MDBase.create_obj(obj)
  326. self._uuids[obj.uuid] = obj
  327. for j in obj.hashes:
  328. h = self.makehash(j)
  329. self._hashes.setdefault(h, []).append(obj)
  330. @classmethod
  331. def load(cls, fname):
  332. '''Load objects from the provided file name.
  333. Basic validation will be done on the objects in the file.
  334. The objects will be accessible via other methods.'''
  335. with open(fname, 'rb') as fp:
  336. objs = _asn1coder.loads(fp.read())
  337. obj = cls(objs['created_by_ref'])
  338. for i in objs['objects']:
  339. obj.loadobj(i)
  340. return obj
  341. def by_id(self, id):
  342. '''Look up an object by it's UUID.'''
  343. if not isinstance(id, uuid.UUID):
  344. uid = uuid.UUID(id)
  345. else:
  346. uid = id
  347. return self._uuids[uid]
  348. def by_hash(self, hash):
  349. '''Look up an object by it's hash value.'''
  350. h = self.makehash(hash, strict=False)
  351. return self._hashes[h]
  352. def by_file(self, fname, types=('metadata', )):
  353. '''Return a metadata object for the file named fname.'''
  354. fid = FileObject.make_id(fname)
  355. try:
  356. fobj = self.by_id(fid)
  357. except KeyError:
  358. # unable to find it
  359. fobj = FileObject.from_file(fname, self._created_by_ref)
  360. self.loadobj(fobj)
  361. # XXX - does not verify
  362. for i in fobj.hashes:
  363. j = self.by_hash(i)
  364. # Filter out non-metadata objects
  365. j = [ x for x in j if x.type in types ]
  366. if j:
  367. return j
  368. else:
  369. raise KeyError('unable to find metadata for file: %s' %
  370. repr(fname))
  371. def _readfp(fp):
  372. while True:
  373. r = fp.read(64*1024)
  374. if r == b'':
  375. return
  376. yield r
  377. def _hashfile(fname):
  378. hash = getattr(hashlib, _defaulthash)()
  379. with open(fname, 'rb') as fp:
  380. for r in _readfp(fp):
  381. hash.update(r)
  382. return '%s:%s' % (_defaulthash, hash.hexdigest())
  383. class FileObject(MDBase):
  384. _type = 'file'
  385. @staticmethod
  386. def make_id(fname):
  387. '''Take a local file name, and make the id for it. Note that
  388. converts from the local path separator to a forward slash so
  389. that it will be the same between Windows and Unix systems.'''
  390. fname = os.path.realpath(fname)
  391. return uuid.uuid5(_NAMESPACE_MEDASHARE_PATH,
  392. '/'.join(os.path.split(fname)))
  393. @classmethod
  394. def from_file(cls, filename, created_by_ref):
  395. s = os.stat(filename)
  396. # XXX - add host uuid?
  397. obj = {
  398. 'dir': os.path.dirname(filename),
  399. 'created_by_ref': created_by_ref,
  400. 'filename': os.path.basename(filename),
  401. 'id': cls.make_id(filename),
  402. 'mtime': datetime.datetime.utcfromtimestamp(s.st_mtime),
  403. 'size': s.st_size,
  404. 'hashes': [ _hashfile(filename), ],
  405. }
  406. return cls(obj)
  407. def enumeratedir(_dir, created_by_ref):
  408. '''Enumerate all the files and directories (not recursive) in _dir.
  409. Returned is a list of FileObjects.'''
  410. return [FileObject.from_file(os.path.join(_dir, x),
  411. created_by_ref) for x in os.listdir(_dir)]
  412. def get_objstore(options):
  413. persona = get_persona(options)
  414. storefname = os.path.expanduser('~/.medashare_store.pasn1')
  415. try:
  416. objstr = ObjectStore.load(storefname)
  417. except FileNotFoundError:
  418. objstr = ObjectStore(persona.get_identity().uuid)
  419. return persona, objstr
  420. def write_objstore(options, objstr):
  421. storefname = os.path.expanduser('~/.medashare_store.pasn1')
  422. objstr.store(storefname)
  423. def get_persona(options):
  424. identfname = os.path.expanduser('~/.medashare_identity.pasn1')
  425. try:
  426. persona = Persona.load(identfname)
  427. except FileNotFoundError:
  428. print('ERROR: Identity not created, create w/ -g.',
  429. file=sys.stderr)
  430. sys.exit(1)
  431. return persona
  432. def cmd_genident(options):
  433. identfname = os.path.expanduser('~/.medashare_identity.pasn1')
  434. if os.path.exists(identfname):
  435. print('Error: Identity already created.', file=sys.stderr)
  436. sys.exit(1)
  437. persona = Persona()
  438. persona.generate_key()
  439. persona.new_version(*(x.split('=', 1) for x in options.tagvalue))
  440. persona.store(identfname)
  441. def cmd_ident(options):
  442. identfname = os.path.expanduser('~/.medashare_identity.pasn1')
  443. persona = Persona.load(identfname)
  444. if options.tagvalue:
  445. persona.new_version(*(x.split('=', 1) for x in
  446. options.tagvalue))
  447. persona.store(identfname)
  448. else:
  449. ident = persona.get_identity()
  450. for k, v in _iterdictlist(ident, skipcommon=False):
  451. print('%s:\t%s' % (k, v))
  452. def cmd_pubkey(options):
  453. identfname = os.path.expanduser('~/.medashare_identity.pasn1')
  454. persona = Persona.load(identfname)
  455. print(persona.get_pubkey().decode('ascii'))
  456. def cmd_modify(options):
  457. persona, objstr = get_objstore(options)
  458. props = [[ x[0] ] + x[1:].split('=', 1) for x in options.modtagvalues]
  459. if any(x[0] not in ('+', '-') for x in props):
  460. print('ERROR: tag needs to start with a "+" (add) or a "-" (remove).', file=sys.stderr)
  461. sys.exit(1)
  462. badtags = list(x[1] for x in props if x[1] in (MDBase._common_names |
  463. MDBase._common_optional))
  464. if any(badtags):
  465. print('ERROR: invalid tag%s: %s.' % ( 's' if
  466. len(badtags) > 1 else '', repr(badtags)), file=sys.stderr)
  467. sys.exit(1)
  468. adds = [ x[1:] for x in props if x[0] == '+' ]
  469. if any((len(x) != 2 for x in adds)):
  470. print('ERROR: invalid tag, needs an "=".', file=sys.stderr)
  471. sys.exit(1)
  472. dels = [ x[1:] for x in props if x[0] == '-' ]
  473. for i in options.files:
  474. # Get MetaData
  475. try:
  476. objs = objstr.by_file(i)
  477. except KeyError:
  478. fobj = persona.by_file(i)
  479. objstr.loadobj(fobj)
  480. objs = [ persona.MetaData(hashes=fobj.hashes) ]
  481. for j in objs:
  482. # make into key/values
  483. obj = j.__to_dict__()
  484. # delete tags
  485. for k in dels:
  486. try:
  487. key, v = k
  488. except ValueError:
  489. del obj[k[0]]
  490. else:
  491. obj[key].remove(v)
  492. # add tags
  493. for k, v in adds:
  494. obj.setdefault(k, []).append(v)
  495. del obj['modified']
  496. nobj = MDBase.create_obj(obj)
  497. objstr.loadobj(nobj)
  498. write_objstore(options, objstr)
  499. def cmd_list(options):
  500. persona, objstr = get_objstore(options)
  501. for i in options.files:
  502. try:
  503. for j in objstr.by_file(i):
  504. #print >>sys.stderr, `j._obj`
  505. for k, v in _iterdictlist(j):
  506. print('%s:\t%s' % (k, v))
  507. except (KeyError, FileNotFoundError):
  508. # XXX - tell the difference?
  509. print('ERROR: file not found: %s' % repr(i),
  510. file=sys.stderr)
  511. sys.exit(1)
  512. def main():
  513. import argparse
  514. parser = argparse.ArgumentParser()
  515. parser.add_argument('--db', '-d', type=str,
  516. help='base name for storage')
  517. subparsers = parser.add_subparsers(title='subcommands',
  518. description='valid subcommands', help='additional help')
  519. parser_gi = subparsers.add_parser('genident', help='generate identity')
  520. parser_gi.add_argument('tagvalue', nargs='+',
  521. help='add the arg as metadata for the identity, tag=[value]')
  522. parser_gi.set_defaults(func=cmd_genident)
  523. parser_i = subparsers.add_parser('ident', help='update identity')
  524. parser_i.add_argument('tagvalue', nargs='*',
  525. help='add the arg as metadata for the identity, tag=[value]')
  526. parser_i.set_defaults(func=cmd_ident)
  527. parser_pubkey = subparsers.add_parser('pubkey', help='print public key of identity')
  528. parser_pubkey.set_defaults(func=cmd_pubkey)
  529. # used so that - isn't treated as an option
  530. parser_mod = subparsers.add_parser('modify', help='modify tags on file(s)', prefix_chars='@')
  531. parser_mod.add_argument('modtagvalues', nargs='+',
  532. help='add (+) or delete (-) the tag=[value], for the specified files')
  533. parser_mod.add_argument('files', nargs='+',
  534. help='files to modify')
  535. parser_mod.set_defaults(func=cmd_modify)
  536. parser_list = subparsers.add_parser('list', help='list tags on file(s)')
  537. parser_list.add_argument('files', nargs='+',
  538. help='files to modify')
  539. parser_list.set_defaults(func=cmd_list)
  540. options = parser.parse_args()
  541. fun = options.func
  542. fun(options)
  543. if __name__ == '__main__': # pragma: no cover
  544. main()
  545. class _TestCononicalCoder(unittest.TestCase):
  546. def test_con(self):
  547. obja = { 'foo': 23984732, 'a': 5, 'b': 6, 'something': '2398472398723498273dfasdfjlaksdfj' }
  548. objaitems = list(obja.items())
  549. objaitems.sort()
  550. objb = dict(objaitems)
  551. self.assertEqual(obja, objb)
  552. # This is to make sure that item order changed
  553. self.assertNotEqual(list(obja.items()), list(objb.items()))
  554. astr = pasn1.dumps(obja)
  555. bstr = pasn1.dumps(objb)
  556. self.assertNotEqual(astr, bstr)
  557. astr = _asn1coder.dumps(obja)
  558. bstr = _asn1coder.dumps(objb)
  559. self.assertEqual(astr, bstr)
  560. class _TestCases(unittest.TestCase):
  561. def setUp(self):
  562. self.fixtures = pathlib.Path('fixtures').resolve()
  563. d = pathlib.Path(tempfile.mkdtemp()).resolve()
  564. self.basetempdir = d
  565. self.tempdir = d / 'subdir'
  566. persona = Persona.load(os.path.join('fixtures', 'sample.persona.pasn1'))
  567. self.created_by_ref = persona.get_identity().uuid
  568. shutil.copytree(self.fixtures / 'testfiles', self.tempdir)
  569. self.oldcwd = os.getcwd()
  570. def tearDown(self):
  571. shutil.rmtree(self.basetempdir)
  572. self.tempdir = None
  573. os.chdir(self.oldcwd)
  574. def test_mdbase(self):
  575. self.assertRaises(ValueError, MDBase, created_by_ref='')
  576. self.assertRaises(ValueError, MDBase.create_obj, { 'type': 'unknosldkfj' })
  577. self.assertRaises(ValueError, MDBase.create_obj, { 'type': 'metadata' })
  578. baseobj = {
  579. 'type': 'metadata',
  580. 'created_by_ref': self.created_by_ref,
  581. }
  582. origbase = copy.deepcopy(baseobj)
  583. # that when an MDBase object is created
  584. md = MDBase.create_obj(baseobj)
  585. # it doesn't modify the passed in object (when adding
  586. # generated properties)
  587. self.assertEqual(baseobj, origbase)
  588. # and it has the generted properties
  589. # Note: cannot mock the functions as they are already
  590. # referenced at creation time
  591. self.assertIn('uuid', md)
  592. self.assertIn('modified', md)
  593. # That you can create a new version using new_version
  594. md2 = md.new_version(('dc:creator', 'Jim Bob',))
  595. # that they are different
  596. self.assertNotEqual(md, md2)
  597. # and that the new modified time is different from the old
  598. self.assertNotEqual(md.modified, md2.modified)
  599. # and that the modification is present
  600. self.assertEqual(md2['dc:creator'], [ 'Jim Bob' ])
  601. # that providing a value from common property
  602. fvalue = 'fakesig'
  603. md3 = md.new_version(('sig', fvalue))
  604. # gets set directly, and is not a list
  605. self.assertEqual(md3.sig, fvalue)
  606. # that invalid attribute access raises correct exception
  607. self.assertRaises(AttributeError, getattr, md, 'somerandombogusattribute')
  608. def test_mdbase_encode_decode(self):
  609. # that an object
  610. baseobj = {
  611. 'type': 'metadata',
  612. 'created_by_ref': self.created_by_ref,
  613. }
  614. obj = MDBase.create_obj(baseobj)
  615. # can be encoded
  616. coded = obj.encode()
  617. # and that the rsults can be decoded
  618. decobj = MDBase.decode(coded)
  619. # and that they are equal
  620. self.assertEqual(obj, decobj)
  621. # and in the encoded object
  622. eobj = _asn1coder.loads(coded)
  623. # the uuid property is a str instance
  624. self.assertIsInstance(eobj['uuid'], bytes)
  625. # and has the length of 16
  626. self.assertEqual(len(eobj['uuid']), 16)
  627. def test_mdbase_wrong_type(self):
  628. # that created_by_ref can be passed by kw
  629. obj = MetaData(created_by_ref=self.created_by_ref)
  630. self.assertRaises(ValueError, FileObject, dict(obj.items(False)))
  631. def test_makehash(self):
  632. self.assertRaises(ValueError, ObjectStore.makehash, 'slkj')
  633. self.assertRaises(ValueError, ObjectStore.makehash, 'sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ADA')
  634. self.assertRaises(ValueError, ObjectStore.makehash, 'bogushash:9e0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ADA', strict=False)
  635. self.assertEqual(ObjectStore.makehash('cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e', strict=False), 'sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e')
  636. self.assertEqual(ObjectStore.makehash('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', strict=False), 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
  637. def test_enumeratedir(self):
  638. files = enumeratedir(self.tempdir, self.created_by_ref)
  639. ftest = files[1]
  640. fname = 'test.txt'
  641. # make sure that they are of type MDBase
  642. self.assertIsInstance(ftest, MDBase)
  643. oldid = ftest.id
  644. self.assertEqual(ftest.filename, fname)
  645. self.assertEqual(ftest.dir, str(self.tempdir))
  646. # XXX - do we add host information?
  647. self.assertEqual(ftest.id, uuid.uuid5(_NAMESPACE_MEDASHARE_PATH,
  648. '/'.join(os.path.split(self.tempdir) +
  649. ( fname, ))))
  650. self.assertEqual(ftest.mtime, datetime.datetime(2019, 5, 20, 21, 47, 36))
  651. self.assertEqual(ftest.size, 15)
  652. self.assertIn('sha512:7d5768d47b6bc27dc4fa7e9732cfa2de506ca262a2749cb108923e5dddffde842bbfee6cb8d692fb43aca0f12946c521cce2633887914ca1f96898478d10ad3f', ftest.hashes)
  653. # XXX - make sure works w/ relative dirs
  654. files = enumeratedir(os.path.relpath(self.tempdir),
  655. self.created_by_ref)
  656. self.assertEqual(oldid, files[1].id)
  657. def test_mdbaseoverlay(self):
  658. objst = ObjectStore(self.created_by_ref)
  659. # that a base object
  660. bid = uuid.uuid4()
  661. objst.loadobj({
  662. 'type': 'metadata',
  663. 'uuid': bid,
  664. 'modified': datetime.datetime(2019, 6, 10, 14, 3, 10),
  665. 'created_by_ref': self.created_by_ref,
  666. 'hashes': [ 'sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada' ],
  667. 'someprop': [ 'somevalue' ],
  668. 'lang': 'en',
  669. })
  670. # can have an overlay object
  671. oid = uuid.uuid4()
  672. dhash = 'sha256:a7c96262c21db9a06fd49e307d694fd95f624569f9b35bb3ffacd880440f9787'
  673. objst.loadobj({
  674. 'type': 'metadata',
  675. 'uuid': oid,
  676. 'modified': datetime.datetime(2019, 6, 10, 18, 3, 10),
  677. 'created_by_ref': self.created_by_ref,
  678. 'hashes': [ dhash ],
  679. 'parent_refs': [ bid ],
  680. 'lang': 'en',
  681. })
  682. # and that when you get it's properties
  683. oobj = objst.by_id(oid)
  684. odict = dict(list(oobj.items()))
  685. # that is has the overlays property
  686. self.assertEqual(odict['parent_refs'], [ bid ])
  687. # that it doesn't have a common property
  688. self.assertNotIn('type', odict)
  689. # that when skipcommon is False
  690. odict = dict(oobj.items(False))
  691. # that it does have a common property
  692. self.assertIn('type', odict)
  693. def test_persona(self):
  694. # that a newly created persona
  695. persona = Persona()
  696. # has an identity object
  697. idobj = persona.get_identity()
  698. # and that it has a uuid attribute that matches
  699. self.assertEqual(persona.uuid, idobj['uuid'])
  700. # that a key can be generated
  701. persona.generate_key()
  702. # that the pubkey property is present
  703. idobj = persona.get_identity()
  704. self.assertIsInstance(idobj['pubkey'], bytes)
  705. # that get_pubkey returns the correct thing
  706. pubstr = _asn1coder.dumps([ idobj.uuid, idobj['pubkey'] ])
  707. self.assertEqual(persona.get_pubkey(),
  708. base58.b58encode_check(pubstr))
  709. # and that there is a signature
  710. self.assertIsInstance(idobj['sig'], bytes)
  711. # and that it can verify itself
  712. persona.verify(idobj)
  713. # and that a new persona can be created from the pubkey
  714. pkpersona = Persona.from_pubkey(persona.get_pubkey())
  715. # and that it can verify the old identity
  716. self.assertTrue(pkpersona.verify(idobj))
  717. # that a second time, it raises an exception
  718. self.assertRaises(RuntimeError, persona.generate_key)
  719. # that a file object created by it
  720. testfname = os.path.join(self.tempdir, 'test.txt')
  721. testobj = persona.by_file(testfname)
  722. # has the correct created_by_ref
  723. self.assertEqual(testobj.created_by_ref, idobj.uuid)
  724. # and has a signature
  725. self.assertIn('sig', testobj)
  726. # that a persona created from the identity object
  727. vpersona = Persona(idobj)
  728. # can verify the sig
  729. self.assertTrue(vpersona.verify(testobj))
  730. # and that a bogus signature
  731. bogussig = 'somebogussig'
  732. bogusobj = MDBase.create_obj(testobj)
  733. bogusobj.sig = bogussig
  734. # fails to verify
  735. self.assertRaises(Exception, vpersona.verify, bogusobj)
  736. # and that a modified object
  737. otherobj = testobj.new_version(('customprop', 'value'))
  738. # fails to verify
  739. self.assertRaises(Exception, vpersona.verify, otherobj)
  740. # that a persona object can be written
  741. perpath = os.path.join(self.basetempdir, 'persona.pasn1')
  742. persona.store(perpath)
  743. # and that when loaded back
  744. loadpersona = Persona.load(perpath)
  745. # the new persona object can sign an object
  746. nvtestobj = loadpersona.sign(testobj.new_version())
  747. # and the old persona can verify it.
  748. self.assertTrue(vpersona.verify(nvtestobj))
  749. def test_persona_metadata(self):
  750. # that a persona
  751. persona = Persona()
  752. persona.generate_key()
  753. # can create a metadata object
  754. hashobj = ['asdlfkj']
  755. mdobj = persona.MetaData(hashes=hashobj)
  756. # that the object has the correct created_by_ref
  757. self.assertEqual(mdobj.created_by_ref, persona.uuid)
  758. # and has the provided hashes
  759. self.assertEqual(mdobj.hashes, hashobj)
  760. # and that it can be verified
  761. persona.verify(mdobj)
  762. # that when round tripped through pasn1.
  763. a = mdobj.encode()
  764. b = MDBase.decode(a)
  765. def test_objectstore(self):
  766. objst = ObjectStore.load(os.path.join('fixtures', 'sample.data.pasn1'))
  767. objst.loadobj({
  768. 'type': 'metadata',
  769. 'uuid': uuid.UUID('c9a1d1e2-3109-4efd-8948-577dc15e44e7'),
  770. 'modified': datetime.datetime(2019, 5, 31, 14, 3, 10),
  771. 'created_by_ref': self.created_by_ref,
  772. 'hashes': [ 'sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada' ],
  773. 'lang': 'en',
  774. })
  775. lst = objst.by_hash('91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada')
  776. self.assertEqual(len(lst), 2)
  777. byid = objst.by_id('3e466e06-45de-4ecc-84ba-2d2a3d970e96')
  778. self.assertIsInstance(byid, MetaData)
  779. self.assertIn(byid, lst)
  780. r = byid
  781. self.assertEqual(r.uuid, uuid.UUID('3e466e06-45de-4ecc-84ba-2d2a3d970e96'))
  782. self.assertEqual(r['dc:creator'], [ 'John-Mark Gurney' ])
  783. fname = 'testfile.pasn1'
  784. objst.store(fname)
  785. with open(fname, 'rb') as fp:
  786. objs = _asn1coder.loads(fp.read())
  787. os.unlink(fname)
  788. self.assertEqual(len(objs), len(objst))
  789. self.assertEqual(objs['created_by_ref'], self.created_by_ref.bytes)
  790. for i in objs['objects']:
  791. i['created_by_ref'] = uuid.UUID(bytes=i['created_by_ref'])
  792. i['uuid'] = uuid.UUID(bytes=i['uuid'])
  793. self.assertEqual(objst.by_id(i['uuid']), i)
  794. testfname = os.path.join(self.tempdir, 'test.txt')
  795. self.assertEqual(objst.by_file(testfname), [ byid ])
  796. self.assertEqual(objst.by_file(testfname), [ byid ])
  797. self.assertRaises(KeyError, objst.by_file, '/dev/null')
  798. # XXX make sure that object store contains fileobject
  799. # Tests to add:
  800. # Non-duplicates when same metadata is located by multiple hashes.
  801. def run_command_file(self, f):
  802. with open(f) as fp:
  803. cmds = json.load(fp)
  804. # setup object store
  805. storefname = self.tempdir / 'storefname'
  806. identfname = self.tempdir / 'identfname'
  807. # setup path mapping
  808. def expandusermock(arg):
  809. if arg == '~/.medashare_store.pasn1':
  810. return storefname
  811. elif arg == '~/.medashare_identity.pasn1':
  812. return identfname
  813. # setup test fname
  814. testfname = os.path.join(self.tempdir, 'test.txt')
  815. newtestfname = os.path.join(self.tempdir, 'newfile.txt')
  816. for cmd in cmds:
  817. try:
  818. special = cmd['special']
  819. except KeyError:
  820. pass
  821. else:
  822. if special == 'copy newfile.txt to test.txt':
  823. shutil.copy(newtestfname, testfname)
  824. elif special == 'change newfile.txt':
  825. with open(newtestfname, 'w') as fp:
  826. fp.write('some new contents')
  827. continue
  828. with self.subTest(file=f, title=cmd['title']), \
  829. mock.patch('os.path.expanduser',
  830. side_effect=expandusermock) as eu, \
  831. mock.patch('sys.stdout', io.StringIO()) as stdout, \
  832. mock.patch('sys.stderr', io.StringIO()) as stderr, \
  833. mock.patch('sys.argv', [ 'progname', ] +
  834. cmd['cmd']) as argv:
  835. with self.assertRaises(SystemExit) as cm:
  836. main()
  837. # XXX - Minor hack till other tests fixed
  838. sys.exit(0)
  839. # with the correct output
  840. self.maxDiff = None
  841. outre = cmd.get('stdout_re')
  842. if outre:
  843. self.assertRegex(stdout.getvalue(), outre)
  844. else:
  845. self.assertEqual(stdout.getvalue(), cmd.get('stdout', ''))
  846. self.assertEqual(stderr.getvalue(), cmd.get('stderr', ''))
  847. self.assertEqual(cm.exception.code, cmd.get('exit', 0))
  848. def test_cmds(self):
  849. cmds = self.fixtures.glob('cmd.*.json')
  850. for i in cmds:
  851. os.chdir(self.tempdir)
  852. self.run_command_file(i)
  853. def test_main(self):
  854. # Test the main runner, this is only testing things that are
  855. # specific to running the program, like where the store is
  856. # created.
  857. # setup object store
  858. storefname = os.path.join(self.tempdir, 'storefname')
  859. identfname = os.path.join(self.tempdir, 'identfname')
  860. # XXX part of the problem
  861. shutil.copy(os.path.join('fixtures', 'sample.data.pasn1'), storefname)
  862. # setup path mapping
  863. def expandusermock(arg):
  864. if arg == '~/.medashare_store.pasn1':
  865. return storefname
  866. elif arg == '~/.medashare_identity.pasn1':
  867. return identfname
  868. # setup test fname
  869. testfname = os.path.join(self.tempdir, 'test.txt')
  870. newtestfname = os.path.join(self.tempdir, 'newfile.txt')
  871. import itertools
  872. real_stderr = sys.stderr
  873. with mock.patch('os.path.expanduser', side_effect=expandusermock) \
  874. as eu, mock.patch('medashare.cli.open') as op:
  875. # that when opening the store and identity fails
  876. op.side_effect = FileNotFoundError
  877. # and there is no identity
  878. with mock.patch('sys.stderr', io.StringIO()) as stderr, mock.patch('sys.argv', [ 'progname', 'list', 'afile' ]) as argv:
  879. with self.assertRaises(SystemExit) as cm:
  880. main()
  881. # that it fails
  882. self.assertEqual(cm.exception.code, 1)
  883. # with the correct error message
  884. self.assertEqual(stderr.getvalue(),
  885. 'ERROR: Identity not created, create w/ -g.\n')
  886. with mock.patch('os.path.expanduser', side_effect=expandusermock) \
  887. as eu:
  888. # that generating a new identity
  889. with mock.patch('sys.stdout', io.StringIO()) as stdout, mock.patch('sys.argv', [ 'progname', 'genident', 'name=A Test User' ]) as argv:
  890. main()
  891. # does not output anything
  892. self.assertEqual(stdout.getvalue(), '')
  893. # looks up the correct file
  894. eu.assert_called_with('~/.medashare_identity.pasn1')
  895. # and that the identity
  896. persona = Persona.load(identfname)
  897. pident = persona.get_identity()
  898. # has the correct name
  899. self.assertEqual(pident.name, 'A Test User')
  900. # that when generating an identity when one already exists
  901. with mock.patch('sys.stderr', io.StringIO()) as stderr, mock.patch('sys.argv', [ 'progname', 'genident', 'name=A Test User' ]) as argv:
  902. # that it exits
  903. with self.assertRaises(SystemExit) as cm:
  904. main()
  905. # with error code 1
  906. self.assertEqual(cm.exception.code, 1)
  907. # and outputs an error message
  908. self.assertEqual(stderr.getvalue(),
  909. 'Error: Identity already created.\n')
  910. # and looked up the correct file
  911. eu.assert_called_with('~/.medashare_identity.pasn1')
  912. # that when updating the identity
  913. with mock.patch('sys.stdout', io.StringIO()) as stdout, mock.patch('sys.argv', [ 'progname', 'ident', 'name=Changed Name' ]) as argv:
  914. main()
  915. # it doesn't output anything
  916. self.assertEqual(stdout.getvalue(), '')
  917. # and looked up the correct file
  918. eu.assert_called_with('~/.medashare_identity.pasn1')
  919. npersona = Persona.load(identfname)
  920. nident = npersona.get_identity()
  921. # and has the new name
  922. self.assertEqual(nident.name, 'Changed Name')
  923. # and has the same old uuid
  924. self.assertEqual(nident.uuid, pident.uuid)
  925. # and that the modified date has changed
  926. self.assertNotEqual(pident.modified, nident.modified)
  927. # and that the old Persona can verify the new one
  928. self.assertTrue(persona.verify(nident))
  929. orig_open = open
  930. with mock.patch('os.path.expanduser', side_effect=expandusermock) \
  931. as eu, mock.patch('medashare.cli.open') as op:
  932. # that when the store fails
  933. def open_repl(fname, mode):
  934. #print('or:', repr(fname), repr(mode), file=sys.stderr)
  935. self.assertIn(mode, ('rb', 'wb'))
  936. if fname == identfname or mode == 'wb':
  937. return orig_open(fname, mode)
  938. #print('foo:', repr(fname), repr(mode), file=sys.stderr)
  939. raise FileNotFoundError
  940. op.side_effect = open_repl
  941. # and there is no store
  942. with mock.patch('sys.stderr', io.StringIO()) as stderr, mock.patch('sys.argv', [ 'progname', 'list', 'foo', ]) as argv:
  943. # that it exits
  944. with self.assertRaises(SystemExit) as cm:
  945. main()
  946. # with error code 1
  947. self.assertEqual(cm.exception.code, 1)
  948. # and outputs an error message
  949. self.assertEqual(stderr.getvalue(),
  950. 'ERROR: file not found: \'foo\'\n')