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.
 
 
 
 

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