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.
 
 
 
 

1867 lines
49 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. from .btv import _TestCases as bttestcase, validate_file
  10. import base64
  11. import base58
  12. from .btv import bencode
  13. import copy
  14. import datetime
  15. import functools
  16. import hashlib
  17. import importlib
  18. import io
  19. import itertools
  20. import json
  21. import magic
  22. import os.path
  23. import pathlib
  24. import pasn1
  25. import re
  26. import shutil
  27. import socket
  28. import string
  29. import sys
  30. import tempfile
  31. import unittest
  32. import uuid
  33. # The UUID for the namespace representing the path to a file
  34. _NAMESPACE_MEDASHARE_PATH = uuid.UUID('f6f36b62-3770-4a68-bc3d-dc3e31e429e6')
  35. _NAMESPACE_MEDASHARE_CONTAINER = uuid.UUID('890a9d5c-0626-4de1-ab05-9e14947391eb')
  36. # useful for debugging when stderr is redirected/captured
  37. _real_stderr = sys.stderr
  38. _defaulthash = 'sha512'
  39. _validhashes = set([ 'sha256', 'sha512' ])
  40. _hashlengths = { len(getattr(hashlib, x)().hexdigest()): x for x in
  41. _validhashes }
  42. def _keyordering(x):
  43. k, v = x
  44. try:
  45. return (MDBase._common_names_list.index(k), k, v)
  46. except ValueError:
  47. return (2**32, k, v)
  48. def _iterdictlist(obj, **kwargs):
  49. l = list(sorted(obj.items(**kwargs), key=_keyordering))
  50. for k, v in l:
  51. if isinstance(v, list):
  52. for i in sorted(v):
  53. yield k, i
  54. else:
  55. yield k, v
  56. def _makeuuid(s):
  57. if isinstance(s, uuid.UUID):
  58. return s
  59. if isinstance(s, bytes):
  60. return uuid.UUID(bytes=s)
  61. else:
  62. return uuid.UUID(s)
  63. def _makedatetime(s):
  64. if isinstance(s, datetime.datetime):
  65. return s
  66. return datetime.datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%fZ').replace(
  67. tzinfo=datetime.timezone.utc)
  68. def _makebytes(s):
  69. if isinstance(s, bytes):
  70. return s
  71. return base64.urlsafe_b64decode(s)
  72. # XXX - known issue, store is not atomic/safe, overwrites in place instead of
  73. # renames
  74. # XXX - add validation
  75. # XXX - how to add singletons
  76. class MDBase(object):
  77. '''This is a simple wrapper that turns a JSON object into a pythonesc
  78. object where attribute accesses work.'''
  79. _type = 'invalid'
  80. _generated_properties = {
  81. 'uuid': uuid.uuid4,
  82. 'modified': lambda: datetime.datetime.now(
  83. tz=datetime.timezone.utc),
  84. }
  85. # When decoding, the decoded value should be passed to this function
  86. # to get the correct type
  87. _instance_properties = {
  88. 'uuid': _makeuuid,
  89. 'modified': _makedatetime,
  90. 'created_by_ref': _makeuuid,
  91. #'parent_refs': lambda x: [ _makeuuid(y) for y in x ],
  92. 'sig': _makebytes,
  93. }
  94. # Override on a per subclass basis
  95. _class_instance_properties = {
  96. }
  97. _common_properties = [ 'type', 'created_by_ref' ] # XXX - add lang?
  98. _common_optional = set(('parent_refs', 'sig'))
  99. _common_names = set(_common_properties + list(
  100. _generated_properties.keys()))
  101. _common_names_list = _common_properties + list(
  102. _generated_properties.keys())
  103. def __init__(self, obj={}, **kwargs):
  104. obj = copy.deepcopy(obj)
  105. obj.update(kwargs)
  106. if self._type == MDBase._type:
  107. raise ValueError('call MDBase.create_obj instead so correct class is used.')
  108. if 'type' in obj and obj['type'] != self._type:
  109. raise ValueError(
  110. 'trying to create the wrong type of object, got: %s, expected: %s' %
  111. (repr(obj['type']), repr(self._type)))
  112. if 'type' not in obj:
  113. obj['type'] = self._type
  114. for x in self._common_properties:
  115. if x not in obj:
  116. raise ValueError('common property %s not present' % repr(x))
  117. for x, fun in itertools.chain(
  118. self._instance_properties.items(),
  119. self._class_instance_properties.items()):
  120. if x in obj:
  121. obj[x] = fun(obj[x])
  122. for x, fun in self._generated_properties.items():
  123. if x not in obj:
  124. obj[x] = fun()
  125. self._obj = obj
  126. @classmethod
  127. def create_obj(cls, obj):
  128. '''Using obj as a base, create an instance of MDBase of the
  129. correct type.
  130. If the correct type is not found, a ValueError is raised.'''
  131. if isinstance(obj, cls):
  132. obj = obj._obj
  133. ty = obj['type']
  134. for i in MDBase.__subclasses__():
  135. if i._type == ty:
  136. return i(obj)
  137. else:
  138. raise ValueError('Unable to find class for type %s' %
  139. repr(ty))
  140. def new_version(self, *args, dels=(), replaces=()):
  141. '''For each k, v pair, add the property k as an additional one
  142. (or new one if first), with the value v.
  143. Any key in dels is removed.
  144. Any k, v pair in replaces, replaces the entire key.'''
  145. obj = copy.deepcopy(self._obj)
  146. common = self._common_names | self._common_optional
  147. for k, v in args:
  148. if k in common:
  149. obj[k] = v
  150. else:
  151. obj.setdefault(k, []).append(v)
  152. for i in dels:
  153. del obj[i]
  154. for k, v in replaces:
  155. obj[k] = v
  156. del obj['modified']
  157. return self.create_obj(obj)
  158. def __repr__(self): # pragma: no cover
  159. return '%s(%s)' % (self.__class__.__name__, repr(self._obj))
  160. def __getattr__(self, k):
  161. try:
  162. return self._obj[k]
  163. except KeyError:
  164. raise AttributeError(k)
  165. def __setattr__(self, k, v):
  166. if k[0] == '_': # direct attribute
  167. self.__dict__[k] = v
  168. else:
  169. self._obj[k] = v
  170. def __getitem__(self, k):
  171. return self._obj[k]
  172. def __to_dict__(self):
  173. '''Returns an internal object. If modification is necessary,
  174. make sure to .copy() it first.'''
  175. return self._obj
  176. def __eq__(self, o):
  177. return self._obj == o
  178. def __contains__(self, k):
  179. return k in self._obj
  180. def items(self, skipcommon=True):
  181. return [ (k, v) for k, v in self._obj.items() if
  182. not skipcommon or k not in self._common_names ]
  183. def encode(self, meth='asn1'):
  184. if meth == 'asn1':
  185. return _asn1coder.dumps(self)
  186. return _jsonencoder.encode(self._obj)
  187. @classmethod
  188. def decode(cls, s, meth='asn1'):
  189. if meth == 'asn1':
  190. obj = _asn1coder.loads(s)
  191. else:
  192. obj = json.loads(s)
  193. return cls.create_obj(obj)
  194. class MetaData(MDBase):
  195. _type = 'metadata'
  196. _uniq_properties = set([ 'ms:tag' ])
  197. class Identity(MDBase):
  198. _type = 'identity'
  199. # Identites don't need a created by
  200. _common_properties = [ x for x in MDBase._common_properties if x !=
  201. 'created_by_ref' ]
  202. _common_optional = set([ x for x in MDBase._common_optional if x !=
  203. 'parent_refs' ] + [ 'name', 'pubkey' ])
  204. _common_names = set(_common_properties + list(
  205. MDBase._generated_properties.keys()))
  206. def _trytodict(o):
  207. if isinstance(o, uuid.UUID):
  208. return 'bytes', o.bytes
  209. try:
  210. return 'dict', o.__to_dict__()
  211. except Exception: # pragma: no cover
  212. raise TypeError('unable to find __to_dict__ on %s: %s' %
  213. (type(o), repr(o)))
  214. class CanonicalCoder(pasn1.ASN1DictCoder):
  215. def enc_dict(self, obj, **kwargs):
  216. class FakeIter:
  217. def items(self):
  218. return iter(sorted(obj.items()))
  219. return pasn1.ASN1DictCoder.enc_dict(self, FakeIter(), **kwargs)
  220. _asn1coder = CanonicalCoder(coerce=_trytodict)
  221. class _JSONEncoder(json.JSONEncoder):
  222. def default(self, o):
  223. if isinstance(o, uuid.UUID):
  224. return str(o)
  225. elif isinstance(o, datetime.datetime):
  226. o = o.astimezone(datetime.timezone.utc)
  227. return o.strftime('%Y-%m-%dT%H:%M:%S.%fZ')
  228. elif isinstance(o, bytes):
  229. return base64.urlsafe_b64encode(o).decode('US-ASCII')
  230. return json.JSONEncoder.default(self, o)
  231. _jsonencoder = _JSONEncoder()
  232. class _TestJSONEncoder(unittest.TestCase):
  233. def test_defaultfailure(self):
  234. class Foo:
  235. pass
  236. self.assertRaises(TypeError, _jsonencoder.encode, Foo())
  237. class Persona(object):
  238. '''The object that represents a persona, or identity. It will
  239. create the proper identity object, serialize for saving keys,
  240. create objects for that persona and other management.'''
  241. def __init__(self, identity=None, key=None):
  242. if identity is None:
  243. self._identity = Identity()
  244. else:
  245. self._identity = identity
  246. self._key = key
  247. self._pubkey = None
  248. if 'pubkey' in self._identity:
  249. pubkeybytes = self._identity.pubkey
  250. self._pubkey = Ed448PublicKey.from_public_bytes(
  251. pubkeybytes)
  252. self._created_by_ref = self._identity.uuid
  253. def Host(self, *args, **kwargs):
  254. kwargs['created_by_ref'] = self.uuid
  255. return self.sign(Host(*args, **kwargs))
  256. def Container(self, *args, **kwargs):
  257. kwargs['created_by_ref'] = self.uuid
  258. return self.sign(Container(*args, **kwargs))
  259. def MetaData(self, *args, **kwargs):
  260. kwargs['created_by_ref'] = self.uuid
  261. return self.sign(MetaData(*args, **kwargs))
  262. @property
  263. def uuid(self):
  264. '''Return the UUID of the identity represented.'''
  265. return self._identity.uuid
  266. def __repr__(self): # pragma: no cover
  267. r = '<Persona: has key: %s, has pubkey: %s, identity: %s>' % \
  268. (self._key is not None, self._pubkey is not None,
  269. repr(self._identity))
  270. return r
  271. @classmethod
  272. def from_pubkey(cls, pubkeystr):
  273. pubstr = base58.b58decode_check(pubkeystr)
  274. uuid, pubkey = _asn1coder.loads(pubstr)
  275. ident = Identity(uuid=uuid, pubkey=pubkey)
  276. return cls(ident)
  277. def get_identity(self):
  278. '''Return the Identity object for this Persona.'''
  279. return self._identity
  280. def get_pubkey(self):
  281. '''Get a printable version of the public key. This is used
  282. for importing into different programs, or for shared.'''
  283. idobj = self._identity
  284. pubstr = _asn1coder.dumps([ idobj.uuid, idobj.pubkey ])
  285. return base58.b58encode_check(pubstr)
  286. def new_version(self, *args):
  287. '''Update the Persona's Identity object.'''
  288. self._identity = self.sign(self._identity.new_version(*args))
  289. return self._identity
  290. def store(self, fname):
  291. '''Store the Persona to a file. If there is a private
  292. key associated w/ the Persona, it will be saved as well.'''
  293. with open(fname, 'wb') as fp:
  294. obj = {
  295. 'identity': self._identity,
  296. }
  297. if self._key is not None:
  298. obj['key'] = \
  299. self._key.private_bytes(Encoding.Raw,
  300. PrivateFormat.Raw, NoEncryption())
  301. fp.write(_asn1coder.dumps(obj))
  302. @classmethod
  303. def load(cls, fname):
  304. '''Load the Persona from the provided file.'''
  305. with open(fname, 'rb') as fp:
  306. objs = _asn1coder.loads(fp.read())
  307. kwargs = {}
  308. if 'key' in objs:
  309. kwargs['key'] = Ed448PrivateKey.from_private_bytes(
  310. objs['key'])
  311. return cls(Identity(objs['identity']), **kwargs)
  312. def generate_key(self):
  313. '''Generate a key for this Identity.
  314. Raises a RuntimeError if a key is already present.'''
  315. if self._key:
  316. raise RuntimeError('a key already exists')
  317. self._key = Ed448PrivateKey.generate()
  318. self._pubkey = self._key.public_key()
  319. pubkey = self._pubkey.public_bytes(Encoding.Raw,
  320. PublicFormat.Raw)
  321. self._identity = self.sign(self._identity.new_version(('pubkey',
  322. pubkey)))
  323. def _makesigbytes(self, obj):
  324. obj = dict(obj.items(False))
  325. try:
  326. del obj['sig']
  327. except KeyError:
  328. pass
  329. return _asn1coder.dumps(obj)
  330. def sign(self, obj):
  331. '''Takes the object, adds a signature, and returns the new
  332. object.'''
  333. sigbytes = self._makesigbytes(obj)
  334. sig = self._key.sign(sigbytes)
  335. newobj = MDBase.create_obj(obj)
  336. newobj.sig = sig
  337. return newobj
  338. def verify(self, obj):
  339. sigbytes = self._makesigbytes(obj)
  340. pubkey = self._pubkey.public_bytes(Encoding.Raw,
  341. PublicFormat.Raw)
  342. self._pubkey.verify(obj['sig'], sigbytes)
  343. return True
  344. def by_file(self, fname):
  345. '''Return a file object for the file named fname.'''
  346. fobj = FileObject.from_file(fname, self._created_by_ref)
  347. return self.sign(fobj)
  348. class ObjectStore(object):
  349. '''A container to store for the various Metadata objects.'''
  350. # The _uuids property contains both the UUIDv4 for objects, and
  351. # looking up the UUIDv5 for FileObjects.
  352. def __init__(self, created_by_ref):
  353. self._created_by_ref = created_by_ref
  354. self._uuids = {}
  355. self._hashes = {}
  356. self._hostuuids = {}
  357. def get_host(self, hostuuid):
  358. return self._hostuuids[hostuuid]
  359. def get_hosts(self):
  360. return self._hostuuids.values()
  361. @staticmethod
  362. def makehash(hashstr, strict=True):
  363. '''Take a hash or hash string, and return a valid hash
  364. string from it.
  365. This makes sure that it is of the correct type and length.
  366. If strict is False, the function will detect the length and
  367. return a valid hash string if one can be found.
  368. By default, the string must be prepended by the type,
  369. followed by a colon, followed by the value in hex in all
  370. lower case characters.'''
  371. try:
  372. hash, value = hashstr.split(':')
  373. except ValueError:
  374. if strict:
  375. raise
  376. hash = _hashlengths[len(hashstr)]
  377. value = hashstr
  378. bvalue = value.encode('ascii')
  379. if strict and len(bvalue.translate(None,
  380. string.hexdigits.lower().encode('ascii'))) != 0:
  381. raise ValueError('value has invalid hex digits (must be lower case)', value)
  382. if hash in _validhashes:
  383. return ':'.join((hash, value))
  384. raise ValueError
  385. def __len__(self):
  386. return len(self._uuids)
  387. def __iter__(self):
  388. seen = set()
  389. for i in self._uuids.values():
  390. if i['uuid'] in seen:
  391. continue
  392. yield i
  393. seen.add(i['uuid'])
  394. def store(self, fname):
  395. '''Write out the objects in the store to the file named
  396. fname.'''
  397. # eliminate objs stored by multiple uuids (FileObjects)
  398. objs = { id(x): x for x in self._uuids.values() }
  399. with open(fname, 'wb') as fp:
  400. obj = {
  401. 'created_by_ref': self._created_by_ref,
  402. 'objects': list(objs.values()),
  403. }
  404. fp.write(_asn1coder.dumps(obj))
  405. def loadobj(self, obj):
  406. '''Load obj into the data store.'''
  407. obj = MDBase.create_obj(obj)
  408. self._uuids[obj.uuid] = obj
  409. if obj.type == 'file':
  410. objid = _makeuuid(obj.id)
  411. if objid in self._uuids:
  412. # pick which obj
  413. oldobj = self._uuids[objid]
  414. if oldobj.modified > obj.modified:
  415. del self._uuids[obj.uuid]
  416. obj = oldobj
  417. else:
  418. # get ride of old obj
  419. del self._uuids[oldobj.uuid]
  420. self._uuids[_makeuuid(obj.id)] = obj
  421. elif obj.type == 'container':
  422. self._uuids[obj.make_id(obj.uri)] = obj
  423. elif obj.type == 'host':
  424. self._uuids[obj.hostuuid] = obj
  425. self._hostuuids[obj.hostuuid] = obj
  426. try:
  427. hashes = obj.hashes
  428. except AttributeError:
  429. pass
  430. else:
  431. for j in hashes:
  432. h = self.makehash(j)
  433. self._hashes.setdefault(h, []).append(obj)
  434. @classmethod
  435. def load(cls, fname):
  436. '''Load objects from the provided file name.
  437. Basic validation will be done on the objects in the file.
  438. The objects will be accessible via other methods.'''
  439. with open(fname, 'rb') as fp:
  440. objs = _asn1coder.loads(fp.read())
  441. obj = cls(objs['created_by_ref'])
  442. for i in objs['objects']:
  443. obj.loadobj(i)
  444. return obj
  445. def drop_uuid(self, uuid):
  446. uuid = _makeuuid(uuid)
  447. obj = self.by_id(uuid)
  448. del self._uuids[uuid]
  449. if obj.type == 'file':
  450. del self._uuids[obj.id]
  451. for j in obj.hashes:
  452. h = self.makehash(j)
  453. self._hashes[h].remove(obj)
  454. def by_id(self, id):
  455. '''Look up an object by it's UUID.'''
  456. uid = _makeuuid(id)
  457. return self._uuids[uid]
  458. def by_hash(self, hash):
  459. '''Look up an object by it's hash value.'''
  460. h = self.makehash(hash, strict=False)
  461. return self._hashes[h]
  462. def get_metadata(self, fname, persona):
  463. '''Get all MetaData objects for fname, or create one if
  464. not found.
  465. If a FileObject is not present, one will be created.
  466. A Persona must be passed in to create the FileObject and
  467. MetaData objects as needed.
  468. Note: if a new MetaData object is created, it is not
  469. stored in the database automatically. It is expected that
  470. it will be modified and then saved, so call ObjectStore.loadobj
  471. with it to save it.
  472. '''
  473. try:
  474. fobj = self.by_file(fname, ('file',))[0]
  475. #print('x:', repr(objs), file=_real_stderr)
  476. except KeyError:
  477. #print('b:', repr(fname), file=_real_stderr)
  478. fobj = persona.by_file(fname)
  479. #print('c:', repr(fobj), file=_real_stderr)
  480. self.loadobj(fobj)
  481. # we now have the fobj, get the metadata for it.
  482. try:
  483. objs = self.by_file(fname)
  484. except KeyError:
  485. objs = [ persona.MetaData(hashes=fobj.hashes) ]
  486. return objs
  487. def by_file(self, fname, types=('metadata', )):
  488. '''Return a metadata object for the file named fname.
  489. Will raise a KeyError if this file does not exist in
  490. the database.
  491. Will raise a ValueError if fname currently does not
  492. match what is in the database.
  493. '''
  494. fid = FileObject.make_id(fname)
  495. #print('bf:', repr(fid), file=_real_stderr)
  496. fobj = self.by_id(fid)
  497. fobj.verify()
  498. for i in fobj.hashes:
  499. j = self.by_hash(i)
  500. # Filter out non-metadata objects
  501. j = [ x for x in j if x.type in types ]
  502. if j:
  503. return j
  504. else:
  505. raise KeyError('unable to find metadata for file: %s' %
  506. repr(fname))
  507. def _readfp(fp):
  508. while True:
  509. r = fp.read(64*1024)
  510. if r == b'':
  511. return
  512. yield r
  513. def _hashfile(fname):
  514. hash = getattr(hashlib, _defaulthash)()
  515. with open(fname, 'rb') as fp:
  516. for r in _readfp(fp):
  517. hash.update(r)
  518. return '%s:%s' % (_defaulthash, hash.hexdigest())
  519. class Host(MDBase):
  520. _type = 'host'
  521. class FileObject(MDBase):
  522. _type = 'file'
  523. _class_instance_properties = {
  524. 'hostid': _makeuuid,
  525. 'id': _makeuuid,
  526. 'mtime': _makedatetime,
  527. }
  528. @staticmethod
  529. def make_id(fname):
  530. '''Take a local file name, and make the id for it. Note that
  531. converts from the local path separator to a forward slash so
  532. that it will be the same between Windows and Unix systems.'''
  533. fname = os.path.realpath(fname)
  534. return uuid.uuid5(_NAMESPACE_MEDASHARE_PATH,
  535. str(hostuuid()) + '/'.join(os.path.split(fname)))
  536. @classmethod
  537. def from_file(cls, filename, created_by_ref):
  538. filename = os.path.abspath(filename)
  539. s = os.stat(filename)
  540. # XXX - race here, fix w/ checking mtime before/after?
  541. obj = {
  542. 'created_by_ref': created_by_ref,
  543. 'hostid': hostuuid(),
  544. 'dir': os.path.dirname(filename),
  545. 'filename': os.path.basename(filename),
  546. 'id': cls.make_id(filename),
  547. 'mtime': datetime.datetime.fromtimestamp(s.st_mtime,
  548. tz=datetime.timezone.utc),
  549. 'size': s.st_size,
  550. 'hashes': [ _hashfile(filename), ],
  551. }
  552. return cls(obj)
  553. def verify(self, complete=False):
  554. '''Verify that this FileObject is still valid. It will
  555. by default, only do a mtime verification.
  556. It will raise a ValueError if the file does not match.'''
  557. s = os.stat(os.path.join(self.dir, self.filename))
  558. mtimets = datetime.datetime.fromtimestamp(s.st_mtime,
  559. tz=datetime.timezone.utc).timestamp()
  560. #print(repr(self), repr(s), s.st_mtime, file=_real_stderr)
  561. if self.mtime.timestamp() != mtimets or \
  562. self.size != s.st_size:
  563. raise ValueError('file %s has changed' %
  564. repr(self.filename))
  565. class Container(MDBase):
  566. _type = 'container'
  567. _common_optional = MDBase._common_optional | set([ 'uri' ])
  568. @staticmethod
  569. def make_id(uri):
  570. return uuid.uuid5(_NAMESPACE_MEDASHARE_CONTAINER, uri)
  571. def enumeratedir(_dir, created_by_ref):
  572. '''Enumerate all the files and directories (not recursive) in _dir.
  573. Returned is a list of FileObjects.'''
  574. return [FileObject.from_file(os.path.join(_dir, x),
  575. created_by_ref) for x in sorted(os.listdir(_dir)) if not
  576. os.path.isdir(os.path.join(_dir, x)) ]
  577. def get_objstore(options):
  578. persona = get_persona(options)
  579. storefname = os.path.expanduser('~/.medashare_store.pasn1')
  580. try:
  581. objstr = ObjectStore.load(storefname)
  582. except FileNotFoundError:
  583. objstr = ObjectStore(persona.get_identity().uuid)
  584. return persona, objstr
  585. def write_objstore(options, objstr):
  586. storefname = os.path.expanduser('~/.medashare_store.pasn1')
  587. objstr.store(storefname)
  588. def get_persona(options):
  589. identfname = os.path.expanduser('~/.medashare_identity.pasn1')
  590. try:
  591. persona = Persona.load(identfname)
  592. except FileNotFoundError:
  593. print('ERROR: Identity not created, create w/ genident.',
  594. file=sys.stderr)
  595. sys.exit(1)
  596. return persona
  597. def cmd_genident(options):
  598. identfname = os.path.expanduser('~/.medashare_identity.pasn1')
  599. if os.path.exists(identfname):
  600. print('Error: Identity already created.', file=sys.stderr)
  601. sys.exit(1)
  602. persona = Persona()
  603. persona.generate_key()
  604. persona.new_version(*(x.split('=', 1) for x in options.tagvalue))
  605. persona.store(identfname)
  606. def cmd_ident(options):
  607. identfname = os.path.expanduser('~/.medashare_identity.pasn1')
  608. persona = Persona.load(identfname)
  609. if options.tagvalue:
  610. persona.new_version(*(x.split('=', 1) for x in
  611. options.tagvalue))
  612. persona.store(identfname)
  613. else:
  614. ident = persona.get_identity()
  615. for k, v in _iterdictlist(ident, skipcommon=False):
  616. print('%s:\t%s' % (k, v))
  617. def cmd_pubkey(options):
  618. identfname = os.path.expanduser('~/.medashare_identity.pasn1')
  619. persona = Persona.load(identfname)
  620. print(persona.get_pubkey().decode('ascii'))
  621. def cmd_modify(options):
  622. persona, objstr = get_objstore(options)
  623. # because of how argparse works, only one file will be collected
  624. # multiple files will end up in modtagvalues, so we need to
  625. # find and move them.
  626. for idx, i in enumerate(options.modtagvalues):
  627. if i[0] not in { '+', '-' }:
  628. # move remaining files
  629. options.files[0:0] = options.modtagvalues[idx:]
  630. del options.modtagvalues[idx:]
  631. break
  632. props = [[ x[0] ] + x[1:].split('=', 1) for x in options.modtagvalues]
  633. if any(x[0] not in ('+', '-') for x in props):
  634. print('ERROR: tag needs to start with a "+" (add) or a "-" (remove).', file=sys.stderr)
  635. sys.exit(1)
  636. badtags = list(x[1] for x in props if x[1] in (MDBase._common_names |
  637. MDBase._common_optional))
  638. if any(badtags):
  639. print('ERROR: invalid tag%s: %s.' % ( 's' if
  640. len(badtags) > 1 else '', repr(badtags)), file=sys.stderr)
  641. sys.exit(1)
  642. adds = [ x[1:] for x in props if x[0] == '+' ]
  643. if any((len(x) != 2 for x in adds)):
  644. print('ERROR: invalid tag, needs an "=".', file=sys.stderr)
  645. sys.exit(1)
  646. dels = [ x[1:] for x in props if x[0] == '-' ]
  647. for i in options.files:
  648. #print('a:', repr(i), file=_real_stderr)
  649. try:
  650. objs = objstr.get_metadata(i, persona)
  651. #print('d:', repr(i), repr(objs), file=_real_stderr)
  652. except FileNotFoundError:
  653. print('ERROR: file not found: %s, or invalid tag specification.' % repr(i), file=sys.stderr)
  654. sys.exit(1)
  655. for j in objs:
  656. #print('c:', repr(j), file=_real_stderr)
  657. # make into key/values
  658. # copy as we modify it later, which is bad
  659. obj = j.__to_dict__().copy()
  660. # delete tags
  661. for k in dels:
  662. try:
  663. key, v = k
  664. except ValueError:
  665. del obj[k[0]]
  666. else:
  667. obj[key].remove(v)
  668. # add tags
  669. uniqify = set()
  670. for k, v in adds:
  671. obj.setdefault(k, []).append(v)
  672. if k in j._uniq_properties:
  673. uniqify.add(k)
  674. for k in uniqify:
  675. obj[k] = list(set(obj[k]))
  676. #print('a:', repr(obj), file=_real_stderr)
  677. del obj['modified']
  678. nobj = MDBase.create_obj(obj)
  679. objstr.loadobj(nobj)
  680. write_objstore(options, objstr)
  681. def printhost(host):
  682. print('%s\t%s' % (host.name, host.hostuuid))
  683. def cmd_hosts(options):
  684. persona, objstr = get_objstore(options)
  685. selfuuid = hostuuid()
  686. try:
  687. host = objstr.get_host(selfuuid)
  688. except KeyError:
  689. host = persona.Host(name=socket.gethostname(), hostuuid=selfuuid)
  690. objstr.loadobj(host)
  691. printhost(host)
  692. hosts = objstr.get_hosts()
  693. for i in hosts:
  694. if i is host:
  695. continue
  696. printhost(i)
  697. write_objstore(options, objstr)
  698. def cmd_dump(options):
  699. persona, objstr = get_objstore(options)
  700. print(persona.get_identity().encode('json'))
  701. for i in objstr:
  702. print(i.encode('json'))
  703. def cmd_auto(options):
  704. for i in options.files:
  705. mf = magic.detect_from_filename(i)
  706. primary = mf[0].split('/', 1)[0]
  707. mt = mf[0]
  708. if primary == 'text':
  709. mt += '; charset=%s' % mf[1]
  710. print('Set:')
  711. print('\tmimetype:\t%s' % mt)
  712. print()
  713. print('Apply (y/N)?')
  714. inp = sys.stdin.readline()
  715. if inp.strip().lower() in ('y', 'yes'):
  716. options.modtagvalues = [ '+mimetype=%s' % mt ]
  717. cmd_modify(options)
  718. def cmd_list(options):
  719. persona, objstr = get_objstore(options)
  720. for i in options.files:
  721. try:
  722. objs = objstr.by_file(i)
  723. except (ValueError, KeyError):
  724. # create the file, it may have the same hash
  725. # as something else
  726. try:
  727. fobj = persona.by_file(i)
  728. objstr.loadobj(fobj)
  729. objs = objstr.by_file(i)
  730. except (FileNotFoundError, KeyError) as e:
  731. print('ERROR: file not found: %s' % repr(i), file=sys.stderr)
  732. sys.exit(1)
  733. except FileNotFoundError:
  734. # XXX - tell the difference?
  735. print('ERROR: file not found: %s' % repr(i),
  736. file=sys.stderr)
  737. sys.exit(1)
  738. for j in objstr.by_file(i):
  739. for k, v in _iterdictlist(j):
  740. print('%s:\t%s' % (k, v))
  741. # This is needed so that if it creates a FileObj, which may be
  742. # expensive (hashing large file), that it gets saved.
  743. write_objstore(options, objstr)
  744. def cmd_container(options):
  745. persona, objstr = get_objstore(options)
  746. for i in options.files:
  747. good, bad = validate_file(i)
  748. if bad:
  749. print('Warning, incomple/invalid files, not added for %s:' % repr(i),
  750. file=sys.stderr)
  751. print('\n'.join('\t%s' %
  752. repr(str(pathlib.Path(*x.parts[1:]))) for x in
  753. sorted(bad)), file=sys.stderr)
  754. files = []
  755. hashes = []
  756. for j in sorted(good):
  757. files.append(str(pathlib.PosixPath(*j.parts[1:])))
  758. try:
  759. fobj = objstr.by_file(j, ('file',))[0]
  760. except:
  761. fobj = persona.by_file(j)
  762. objstr.loadobj(fobj)
  763. # XXX - ensure only one is added?
  764. hashes.extend(fobj.hashes)
  765. with open(i, 'rb') as fp:
  766. torrent = bencode.bdecode(fp.read())
  767. bencodedinfo = bencode.bencode(torrent['info'])
  768. infohash = hashlib.sha1(bencodedinfo).hexdigest()
  769. # XXX - not entirely happy w/ URI
  770. uri = 'magnet:?xt=urn:btih:%s&dn=%s' % (infohash,
  771. torrent['info']['name'].decode('utf-8'))
  772. kwargs = dict(files=files, hashes=hashes,
  773. uri=uri)
  774. if bad:
  775. kwargs['incomplete'] = True
  776. # XXX - doesn't combine files/hashes, that is if a
  777. # Container has one set of good files, and then the
  778. # next scan has a different set, only the second set
  779. # will be present, not any from the first set.
  780. try:
  781. cont = objstr.by_id(Container.make_id(uri))
  782. cont = cont.new_version(*kwargs.items(), dels=() if bad
  783. else ('incomplete',), replaces=kwargs.items())
  784. except KeyError:
  785. cont = persona.Container(**kwargs)
  786. objstr.loadobj(cont)
  787. write_objstore(options, objstr)
  788. def cmd_import(options):
  789. persona, objstr = get_objstore(options)
  790. jd = json.JSONDecoder()
  791. inp = sys.stdin.read()
  792. while inp:
  793. inp = inp.strip()
  794. jobj, endpos = jd.raw_decode(inp)
  795. if options.sign:
  796. cbr = _makeuuid(jobj['created_by_ref'])
  797. if cbr != persona.uuid:
  798. # new owner
  799. jobj['created_by_ref'] = persona.uuid
  800. # drop old parts
  801. jobj.pop('uuid', None)
  802. jobj.pop('modified', None)
  803. obj = MDBase.create_obj(jobj)
  804. if options.sign:
  805. obj = persona.sign(obj)
  806. objstr.loadobj(obj)
  807. inp = inp[endpos:]
  808. write_objstore(options, objstr)
  809. def cmd_drop(options):
  810. persona, objstr = get_objstore(options)
  811. for i in options.uuids:
  812. objstr.drop_uuid(i)
  813. write_objstore(options, objstr)
  814. def main():
  815. import argparse
  816. parser = argparse.ArgumentParser()
  817. parser.add_argument('--db', '-d', type=str,
  818. help='base name for storage')
  819. subparsers = parser.add_subparsers(title='subcommands',
  820. description='valid subcommands', help='additional help')
  821. parser_gi = subparsers.add_parser('genident', help='generate identity')
  822. parser_gi.add_argument('tagvalue', nargs='+',
  823. help='add the arg as metadata for the identity, tag=[value]')
  824. parser_gi.set_defaults(func=cmd_genident)
  825. parser_i = subparsers.add_parser('ident', help='update identity')
  826. parser_i.add_argument('tagvalue', nargs='*',
  827. help='add the arg as metadata for the identity, tag=[value]')
  828. parser_i.set_defaults(func=cmd_ident)
  829. parser_pubkey = subparsers.add_parser('pubkey', help='print public key of identity')
  830. parser_pubkey.set_defaults(func=cmd_pubkey)
  831. # used so that - isn't treated as an option
  832. parser_mod = subparsers.add_parser('modify', help='modify tags on file(s)', prefix_chars='@')
  833. parser_mod.add_argument('modtagvalues', nargs='+',
  834. help='add (+) or delete (-) the tag=[value], for the specified files')
  835. parser_mod.add_argument('files', nargs='+',
  836. help='files to modify')
  837. parser_mod.set_defaults(func=cmd_modify)
  838. parser_auto = subparsers.add_parser('auto', help='automatic detection of file properties')
  839. parser_auto.add_argument('files', nargs='+',
  840. help='files to modify')
  841. parser_auto.set_defaults(func=cmd_auto)
  842. parser_list = subparsers.add_parser('list', help='list tags on file(s)')
  843. parser_list.add_argument('files', nargs='+',
  844. help='files to modify')
  845. parser_list.set_defaults(func=cmd_list)
  846. parser_container = subparsers.add_parser('container', help='file is examined as a container and the internal files imported as entries')
  847. parser_container.add_argument('files', nargs='+',
  848. help='files to modify')
  849. parser_container.set_defaults(func=cmd_container)
  850. parser_hosts = subparsers.add_parser('hosts', help='dump all the hosts, self is always first')
  851. parser_hosts.set_defaults(func=cmd_hosts)
  852. parser_dump = subparsers.add_parser('dump', help='dump all the objects')
  853. parser_dump.set_defaults(func=cmd_dump)
  854. parser_import = subparsers.add_parser('import', help='import objects encoded as json')
  855. parser_import.add_argument('--sign', action='store_true',
  856. help='import as new identity, and sign objects (if created_by_ref is different, new uuid is created)')
  857. parser_import.set_defaults(func=cmd_import)
  858. parser_drop = subparsers.add_parser('drop', help='drop the object specified by UUID')
  859. parser_drop.add_argument('uuids', nargs='+',
  860. help='UUID of object to drop')
  861. parser_drop.set_defaults(func=cmd_drop)
  862. options = parser.parse_args()
  863. fun = options.func
  864. fun(options)
  865. if __name__ == '__main__': # pragma: no cover
  866. main()
  867. class _TestCononicalCoder(unittest.TestCase):
  868. def test_con(self):
  869. # make a dict
  870. obja = {
  871. 'foo': 23984732, 'a': 5, 'b': 6,
  872. 'something': '2398472398723498273dfasdfjlaksdfj'
  873. }
  874. # reorder the items in it
  875. objaitems = list(obja.items())
  876. objaitems.sort()
  877. objb = dict(objaitems)
  878. # and they are still the same
  879. self.assertEqual(obja, objb)
  880. # This is to make sure that item order changed
  881. self.assertNotEqual(list(obja.items()), list(objb.items()))
  882. astr = pasn1.dumps(obja)
  883. bstr = pasn1.dumps(objb)
  884. # that they normally will be serialized differently
  885. self.assertNotEqual(astr, bstr)
  886. # but w/ the special encoder
  887. astr = _asn1coder.dumps(obja)
  888. bstr = _asn1coder.dumps(objb)
  889. # they are now encoded the same
  890. self.assertEqual(astr, bstr)
  891. class _TestCases(unittest.TestCase):
  892. def setUp(self):
  893. self.fixtures = pathlib.Path('fixtures').resolve()
  894. d = pathlib.Path(tempfile.mkdtemp()).resolve()
  895. self.basetempdir = d
  896. self.tempdir = d / 'subdir'
  897. self.persona = Persona.load(os.path.join('fixtures', 'sample.persona.pasn1'))
  898. self.created_by_ref = self.persona.get_identity().uuid
  899. shutil.copytree(self.fixtures / 'testfiles', self.tempdir)
  900. self.oldcwd = os.getcwd()
  901. def tearDown(self):
  902. shutil.rmtree(self.basetempdir)
  903. self.tempdir = None
  904. os.chdir(self.oldcwd)
  905. def test_fileobject(self):
  906. os.chdir(self.tempdir)
  907. objst = ObjectStore(self.created_by_ref)
  908. a = self.persona.by_file('test.txt')
  909. # that the dir is absolute
  910. self.assertEqual(a.dir[0], '/')
  911. # make sure the file's hostid is a UUID
  912. self.assertIsInstance(a.hostid, uuid.UUID)
  913. # make sure the file's id is a UUID
  914. self.assertIsInstance(a.id, uuid.UUID)
  915. objst.loadobj(a)
  916. # write out the store
  917. objst.store('teststore.pasn1')
  918. # load it back in
  919. objstr = ObjectStore.load('teststore.pasn1')
  920. a = objstr.by_id(a['uuid'])
  921. # make sure the hostid is still a UUID
  922. self.assertIsInstance(a.hostid, uuid.UUID)
  923. # make sure the file's id is still a UUID
  924. self.assertIsInstance(a.id, uuid.UUID)
  925. # That it can be encoded to json
  926. jsfo = a.encode('json')
  927. # that it can be decoded from json
  928. jsloadedfo = MDBase.decode(jsfo, 'json')
  929. # and that it is equal
  930. self.assertEqual(jsloadedfo, a)
  931. def test_mdbase(self):
  932. self.assertRaises(ValueError, MDBase, created_by_ref='')
  933. self.assertRaises(ValueError, MDBase.create_obj, { 'type': 'unknosldkfj' })
  934. self.assertRaises(ValueError, MDBase.create_obj, { 'type': 'metadata' })
  935. baseobj = {
  936. 'type': 'metadata',
  937. 'created_by_ref': self.created_by_ref,
  938. }
  939. origbase = copy.deepcopy(baseobj)
  940. # that when an MDBase object is created
  941. md = MDBase.create_obj(baseobj)
  942. # it doesn't modify the passed in object (when adding
  943. # generated properties)
  944. self.assertEqual(baseobj, origbase)
  945. # and it has the generted properties
  946. # Note: cannot mock the functions as they are already
  947. # referenced at creation time
  948. self.assertIn('uuid', md)
  949. self.assertIn('modified', md)
  950. # That you can create a new version using new_version
  951. md2 = md.new_version(('dc:creator', 'Jim Bob',))
  952. # that they are different
  953. self.assertNotEqual(md, md2)
  954. # and that the new modified time is different from the old
  955. self.assertNotEqual(md.modified, md2.modified)
  956. # and that the modification is present
  957. self.assertEqual(md2['dc:creator'], [ 'Jim Bob' ])
  958. # that providing a value from common property
  959. fvalue = b'fakesig'
  960. md3 = md.new_version(('sig', fvalue))
  961. # gets set directly, and is not a list
  962. self.assertEqual(md3.sig, fvalue)
  963. # that invalid attribute access raises correct exception
  964. self.assertRaises(AttributeError, getattr, md, 'somerandombogusattribute')
  965. def test_mdbase_encode_decode(self):
  966. # that an object
  967. baseobj = {
  968. 'type': 'metadata',
  969. 'created_by_ref': self.created_by_ref,
  970. }
  971. obj = MDBase.create_obj(baseobj)
  972. # can be encoded
  973. coded = obj.encode()
  974. # and that the rsults can be decoded
  975. decobj = MDBase.decode(coded)
  976. # and that they are equal
  977. self.assertEqual(obj, decobj)
  978. # and in the encoded object
  979. eobj = _asn1coder.loads(coded)
  980. # the uuid property is a str instance
  981. self.assertIsInstance(eobj['uuid'], bytes)
  982. # and has the length of 16
  983. self.assertEqual(len(eobj['uuid']), 16)
  984. # and that json can be used to encode
  985. js = obj.encode('json')
  986. # and that it is valid json
  987. jsobj = json.loads(js)
  988. # and that it can be decoded
  989. jsdecobj = MDBase.decode(js, 'json')
  990. # and that it matches
  991. self.assertEqual(jsdecobj, obj)
  992. for key, inval in [
  993. ('modified', '2022-08-19T01:27:34.258676'),
  994. ('modified', '2022-08-19T01:27:34Z'),
  995. ('modified', '2022-08-19T01:27:34.258676+00:00'),
  996. ('uuid', 'z5336176-8086-4c21-984f-fda60ddaa172'),
  997. ('uuid', '05336176-8086-421-984f-fda60ddaa172'),
  998. ]:
  999. jsobj['modified'] = inval
  1000. jstest = json.dumps(jsobj)
  1001. self.assertRaises(ValueError, MDBase.decode, jstest, 'json')
  1002. def test_mdbase_wrong_type(self):
  1003. # that created_by_ref can be passed by kw
  1004. obj = MetaData(created_by_ref=self.created_by_ref)
  1005. self.assertRaises(ValueError, FileObject, dict(obj.items(False)))
  1006. def test_makehash(self):
  1007. self.assertRaises(ValueError, ObjectStore.makehash, 'slkj')
  1008. self.assertRaises(ValueError, ObjectStore.makehash, 'sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ADA')
  1009. self.assertRaises(ValueError, ObjectStore.makehash, 'bogushash:9e0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ADA', strict=False)
  1010. self.assertEqual(ObjectStore.makehash('cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e', strict=False), 'sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e')
  1011. self.assertEqual(ObjectStore.makehash('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', strict=False), 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
  1012. def test_enumeratedir(self):
  1013. files = enumeratedir(self.tempdir, self.created_by_ref)
  1014. ftest = [ x for x in files if x.filename == 'test.txt' ][0]
  1015. fname = 'test.txt'
  1016. # make sure that they are of type MDBase
  1017. self.assertIsInstance(ftest, MDBase)
  1018. oldid = ftest.id
  1019. self.assertEqual(ftest.filename, fname)
  1020. self.assertEqual(ftest.dir, str(self.tempdir))
  1021. # XXX - do we add host information?
  1022. self.assertEqual(ftest.id, uuid.uuid5(_NAMESPACE_MEDASHARE_PATH,
  1023. str(hostuuid()) + '/'.join(os.path.split(self.tempdir) +
  1024. ( fname, ))))
  1025. self.assertEqual(ftest.mtime, datetime.datetime(2019, 5, 20,
  1026. 21, 47, 36, tzinfo=datetime.timezone.utc))
  1027. self.assertEqual(ftest.size, 15)
  1028. self.assertIn('sha512:7d5768d47b6bc27dc4fa7e9732cfa2de506ca262a2749cb108923e5dddffde842bbfee6cb8d692fb43aca0f12946c521cce2633887914ca1f96898478d10ad3f', ftest.hashes)
  1029. # XXX - make sure works w/ relative dirs
  1030. files = enumeratedir(os.path.relpath(self.tempdir),
  1031. self.created_by_ref)
  1032. self.assertEqual(oldid, files[1].id)
  1033. def test_mdbaseoverlay(self):
  1034. objst = ObjectStore(self.created_by_ref)
  1035. # that a base object
  1036. bid = uuid.uuid4()
  1037. objst.loadobj({
  1038. 'type': 'metadata',
  1039. 'uuid': bid,
  1040. 'modified': datetime.datetime(2019, 6, 10, 14, 3, 10),
  1041. 'created_by_ref': self.created_by_ref,
  1042. 'hashes': [ 'sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada' ],
  1043. 'someprop': [ 'somevalue' ],
  1044. 'lang': 'en',
  1045. })
  1046. # can have an overlay object
  1047. oid = uuid.uuid4()
  1048. dhash = 'sha256:a7c96262c21db9a06fd49e307d694fd95f624569f9b35bb3ffacd880440f9787'
  1049. objst.loadobj({
  1050. 'type': 'metadata',
  1051. 'uuid': oid,
  1052. 'modified': datetime.datetime(2019, 6, 10, 18, 3, 10),
  1053. 'created_by_ref': self.created_by_ref,
  1054. 'hashes': [ dhash ],
  1055. 'parent_refs': [ bid ],
  1056. 'lang': 'en',
  1057. })
  1058. # and that when you get it's properties
  1059. oobj = objst.by_id(oid)
  1060. odict = dict(list(oobj.items()))
  1061. # that is has the overlays property
  1062. self.assertEqual(odict['parent_refs'], [ bid ])
  1063. # that it doesn't have a common property
  1064. self.assertNotIn('type', odict)
  1065. # that when skipcommon is False
  1066. odict = dict(oobj.items(False))
  1067. # that it does have a common property
  1068. self.assertIn('type', odict)
  1069. def test_persona(self):
  1070. # that a newly created persona
  1071. persona = Persona()
  1072. # has an identity object
  1073. idobj = persona.get_identity()
  1074. # and that it has a uuid attribute that matches
  1075. self.assertEqual(persona.uuid, idobj['uuid'])
  1076. # that a key can be generated
  1077. persona.generate_key()
  1078. # that the pubkey property is present
  1079. idobj = persona.get_identity()
  1080. self.assertIsInstance(idobj['pubkey'], bytes)
  1081. # that get_pubkey returns the correct thing
  1082. pubstr = _asn1coder.dumps([ idobj.uuid, idobj['pubkey'] ])
  1083. self.assertEqual(persona.get_pubkey(),
  1084. base58.b58encode_check(pubstr))
  1085. # and that there is a signature
  1086. self.assertIsInstance(idobj['sig'], bytes)
  1087. # and that it can verify itself
  1088. persona.verify(idobj)
  1089. # and that a new persona can be created from the pubkey
  1090. pkpersona = Persona.from_pubkey(persona.get_pubkey())
  1091. # and that it can verify the old identity
  1092. self.assertTrue(pkpersona.verify(idobj))
  1093. # that a second time, it raises an exception
  1094. self.assertRaises(RuntimeError, persona.generate_key)
  1095. # that a file object created by it
  1096. testfname = os.path.join(self.tempdir, 'test.txt')
  1097. testobj = persona.by_file(testfname)
  1098. # has the correct created_by_ref
  1099. self.assertEqual(testobj.created_by_ref, idobj.uuid)
  1100. self.assertEqual(testobj.type, 'file')
  1101. # and has a signature
  1102. self.assertIn('sig', testobj)
  1103. # that a persona created from the identity object
  1104. vpersona = Persona(idobj)
  1105. # can verify the sig
  1106. self.assertTrue(vpersona.verify(testobj))
  1107. # and that a bogus signature
  1108. bogussig = 'somebogussig'
  1109. bogusobj = MDBase.create_obj(testobj)
  1110. bogusobj.sig = bogussig
  1111. # fails to verify
  1112. self.assertRaises(Exception, vpersona.verify, bogusobj)
  1113. # and that a modified object
  1114. otherobj = testobj.new_version(('customprop', 'value'))
  1115. # fails to verify
  1116. self.assertRaises(Exception, vpersona.verify, otherobj)
  1117. # that a persona object can be written
  1118. perpath = os.path.join(self.basetempdir, 'persona.pasn1')
  1119. persona.store(perpath)
  1120. # and that when loaded back
  1121. loadpersona = Persona.load(perpath)
  1122. # the new persona object can sign an object
  1123. nvtestobj = loadpersona.sign(testobj.new_version())
  1124. # and the old persona can verify it.
  1125. self.assertTrue(vpersona.verify(nvtestobj))
  1126. def test_persona_metadata(self):
  1127. # that a persona
  1128. persona = Persona()
  1129. persona.generate_key()
  1130. # can create a metadata object
  1131. hashobj = ['asdlfkj']
  1132. mdobj = persona.MetaData(hashes=hashobj)
  1133. # that the object has the correct created_by_ref
  1134. self.assertEqual(mdobj.created_by_ref, persona.uuid)
  1135. # and has the provided hashes
  1136. self.assertEqual(mdobj.hashes, hashobj)
  1137. # and that it can be verified
  1138. persona.verify(mdobj)
  1139. def test_objectstore(self):
  1140. persona = Persona.load(os.path.join('fixtures', 'sample.persona.pasn1'))
  1141. objst = ObjectStore.load(os.path.join('fixtures', 'sample.data.pasn1'))
  1142. objst.loadobj({
  1143. 'type': 'metadata',
  1144. 'uuid': uuid.UUID('c9a1d1e2-3109-4efd-8948-577dc15e44e7'),
  1145. 'modified': datetime.datetime(2019, 5, 31, 14, 3, 10,
  1146. tzinfo=datetime.timezone.utc),
  1147. 'created_by_ref': self.created_by_ref,
  1148. 'hashes': [ 'sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada' ],
  1149. 'lang': 'en',
  1150. })
  1151. lst = objst.by_hash('91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada')
  1152. self.assertEqual(len(lst), 2)
  1153. byid = objst.by_id('3e466e06-45de-4ecc-84ba-2d2a3d970e96')
  1154. self.assertIsInstance(byid, MetaData)
  1155. self.assertIn(byid, lst)
  1156. r = byid
  1157. self.assertEqual(r.uuid, uuid.UUID('3e466e06-45de-4ecc-84ba-2d2a3d970e96'))
  1158. self.assertEqual(r['dc:creator'], [ 'John-Mark Gurney' ])
  1159. # test storing the object store
  1160. fname = 'testfile.pasn1'
  1161. objst.store(fname)
  1162. with open(fname, 'rb') as fp:
  1163. objs = _asn1coder.loads(fp.read())
  1164. os.unlink(fname)
  1165. self.assertEqual(len(objs), len(objst))
  1166. self.assertEqual(objs['created_by_ref'], self.created_by_ref.bytes)
  1167. # make sure that the read back data matches
  1168. for i in objs['objects']:
  1169. i['created_by_ref'] = uuid.UUID(bytes=i['created_by_ref'])
  1170. i['uuid'] = uuid.UUID(bytes=i['uuid'])
  1171. self.assertEqual(objst.by_id(i['uuid']), i)
  1172. # that a file
  1173. testfname = os.path.join(self.tempdir, 'test.txt')
  1174. # when registered
  1175. objst.loadobj(persona.by_file(testfname))
  1176. # can be found
  1177. self.assertEqual(objst.by_file(testfname), [ byid ])
  1178. self.assertEqual(objst.by_file(testfname), [ byid ])
  1179. self.assertRaises(KeyError, objst.by_file, '/dev/null')
  1180. # XXX make sure that object store contains fileobject
  1181. # Tests to add:
  1182. # Non-duplicates when same metadata is located by multiple hashes.
  1183. def run_command_file(self, f):
  1184. with open(f) as fp:
  1185. cmds = json.load(fp)
  1186. # setup object store
  1187. storefname = self.tempdir / 'storefname'
  1188. identfname = self.tempdir / 'identfname'
  1189. # setup path mapping
  1190. def expandusermock(arg):
  1191. if arg == '~/.medashare_store.pasn1':
  1192. return storefname
  1193. elif arg == '~/.medashare_identity.pasn1':
  1194. return identfname
  1195. # setup test fname
  1196. testfname = os.path.join(self.tempdir, 'test.txt')
  1197. newtestfname = os.path.join(self.tempdir, 'newfile.txt')
  1198. patches = []
  1199. for cmd in cmds:
  1200. try:
  1201. if cmd['skip']: # pragma: no cover
  1202. continue
  1203. except KeyError:
  1204. pass
  1205. try:
  1206. special = cmd['special']
  1207. except KeyError:
  1208. pass
  1209. else:
  1210. if special == 'copy newfile.txt to test.txt':
  1211. shutil.copy(newtestfname, testfname)
  1212. elif special == 'change newfile.txt':
  1213. with open(newtestfname, 'w') as fp:
  1214. fp.write('some new contents')
  1215. elif special == 'verify store object cnt':
  1216. with open(storefname, 'rb') as fp:
  1217. pasn1obj = pasn1.loads(fp.read())
  1218. objcnt = len(pasn1obj['objects'])
  1219. self.assertEqual(objcnt, cmd['count'])
  1220. elif special == 'set hostid':
  1221. hostidpatch = mock.patch(__name__ + '.hostuuid')
  1222. hid = cmd['hostid'] if 'hostid' in cmd else uuid.uuid4()
  1223. hostidpatch.start().return_value = hid
  1224. patches.append(hostidpatch)
  1225. elif special == 'iter is unique':
  1226. objst = ObjectStore.load(storefname)
  1227. uniqobjs = len(set((x['uuid'] for x in objst)))
  1228. self.assertEqual(len(list(objst)), uniqobjs)
  1229. elif special == 'setup bittorrent files':
  1230. # copy in the torrent file
  1231. tor = importlib.resources.files('medashare.btv')
  1232. tor = tor / 'fixtures' / 'somedir.torrent'
  1233. shutil.copy(tor, self.tempdir)
  1234. # partly recreate files
  1235. btfiles = bttestcase.origfiledata.copy()
  1236. if not cmd['complete']:
  1237. btfiles.update(bttestcase.badfiles)
  1238. sd = self.tempdir / bttestcase.dirname
  1239. sd.mkdir(exist_ok=True)
  1240. bttestcase.make_files(sd, btfiles)
  1241. else: # pragma: no cover
  1242. raise ValueError('unhandled special: %s' % repr(special))
  1243. # coverage bug, fixed in 3.10:
  1244. # https://github.com/nedbat/coveragepy/issues/1432#event-7130600158
  1245. if True: # pragma: no cover
  1246. continue
  1247. with self.subTest(file=f, title=cmd['title']), \
  1248. mock.patch('os.path.expanduser',
  1249. side_effect=expandusermock) as eu, \
  1250. mock.patch('sys.stdin', io.StringIO()) as stdin, \
  1251. mock.patch('sys.stdout', io.StringIO()) as stdout, \
  1252. mock.patch('sys.stderr', io.StringIO()) as stderr, \
  1253. mock.patch('sys.argv', [ 'progname', ] +
  1254. cmd['cmd']) as argv:
  1255. # if there is stdin
  1256. test_stdin = cmd.get('stdin', '')
  1257. # provide it
  1258. stdin.write(test_stdin)
  1259. stdin.seek(0)
  1260. with self.assertRaises(SystemExit) as cm:
  1261. main()
  1262. # XXX - Minor hack till other tests fixed
  1263. sys.exit(0)
  1264. # with the correct output
  1265. self.maxDiff = None
  1266. outnre = cmd.get('stdout_nre')
  1267. outre = cmd.get('stdout_re')
  1268. if outnre:
  1269. self.assertNotRegex(stdout.getvalue(), outnre)
  1270. elif outre:
  1271. self.assertRegex(stdout.getvalue(), outre)
  1272. else:
  1273. self.assertEqual(stdout.getvalue(), cmd.get('stdout', ''))
  1274. self.assertEqual(stderr.getvalue(), cmd.get('stderr', ''))
  1275. self.assertEqual(cm.exception.code, cmd.get('exit', 0))
  1276. patches.reverse()
  1277. for i in patches:
  1278. i.stop()
  1279. def test_cmds(self):
  1280. cmds = sorted(self.fixtures.glob('cmd.*.json'))
  1281. for i in cmds:
  1282. # make sure each file starts with a clean slate
  1283. self.tearDown()
  1284. self.setUp()
  1285. os.chdir(self.tempdir)
  1286. self.run_command_file(i)
  1287. # XXX - the following test may no longer be needed
  1288. def test_main(self):
  1289. # Test the main runner, this is only testing things that are
  1290. # specific to running the program, like where the store is
  1291. # created.
  1292. # setup object store
  1293. storefname = os.path.join(self.tempdir, 'storefname')
  1294. identfname = os.path.join(self.tempdir, 'identfname')
  1295. # XXX part of the problem
  1296. shutil.copy(os.path.join('fixtures', 'sample.data.pasn1'), storefname)
  1297. # setup path mapping
  1298. def expandusermock(arg):
  1299. if arg == '~/.medashare_store.pasn1':
  1300. return storefname
  1301. elif arg == '~/.medashare_identity.pasn1':
  1302. return identfname
  1303. # setup test fname
  1304. testfname = os.path.join(self.tempdir, 'test.txt')
  1305. newtestfname = os.path.join(self.tempdir, 'newfile.txt')
  1306. import itertools
  1307. with mock.patch('os.path.expanduser', side_effect=expandusermock) \
  1308. as eu, mock.patch('medashare.cli.open') as op:
  1309. # that when opening the store and identity fails
  1310. op.side_effect = FileNotFoundError
  1311. # and there is no identity
  1312. with mock.patch('sys.stderr', io.StringIO()) as stderr, mock.patch('sys.argv', [ 'progname', 'list', 'afile' ]) as argv:
  1313. with self.assertRaises(SystemExit) as cm:
  1314. main()
  1315. # that it fails
  1316. self.assertEqual(cm.exception.code, 1)
  1317. # with the correct error message
  1318. self.assertEqual(stderr.getvalue(),
  1319. 'ERROR: Identity not created, create w/ genident.\n')
  1320. with mock.patch('os.path.expanduser', side_effect=expandusermock) \
  1321. as eu:
  1322. # that generating a new identity
  1323. with mock.patch('sys.stdout', io.StringIO()) as stdout, mock.patch('sys.argv', [ 'progname', 'genident', 'name=A Test User' ]) as argv:
  1324. main()
  1325. # does not output anything
  1326. self.assertEqual(stdout.getvalue(), '')
  1327. # looks up the correct file
  1328. eu.assert_called_with('~/.medashare_identity.pasn1')
  1329. # and that the identity
  1330. persona = Persona.load(identfname)
  1331. pident = persona.get_identity()
  1332. # has the correct name
  1333. self.assertEqual(pident.name, 'A Test User')
  1334. # that when generating an identity when one already exists
  1335. with mock.patch('sys.stderr', io.StringIO()) as stderr, mock.patch('sys.argv', [ 'progname', 'genident', 'name=A Test User' ]) as argv:
  1336. # that it exits
  1337. with self.assertRaises(SystemExit) as cm:
  1338. main()
  1339. # with error code 1
  1340. self.assertEqual(cm.exception.code, 1)
  1341. # and outputs an error message
  1342. self.assertEqual(stderr.getvalue(),
  1343. 'Error: Identity already created.\n')
  1344. # and looked up the correct file
  1345. eu.assert_called_with('~/.medashare_identity.pasn1')
  1346. # that when updating the identity
  1347. with mock.patch('sys.stdout', io.StringIO()) as stdout, mock.patch('sys.argv', [ 'progname', 'ident', 'name=Changed Name' ]) as argv:
  1348. main()
  1349. # it doesn't output anything
  1350. self.assertEqual(stdout.getvalue(), '')
  1351. # and looked up the correct file
  1352. eu.assert_called_with('~/.medashare_identity.pasn1')
  1353. npersona = Persona.load(identfname)
  1354. nident = npersona.get_identity()
  1355. # and has the new name
  1356. self.assertEqual(nident.name, 'Changed Name')
  1357. # and has the same old uuid
  1358. self.assertEqual(nident.uuid, pident.uuid)
  1359. # and that the modified date has changed
  1360. self.assertNotEqual(pident.modified, nident.modified)
  1361. # and that the old Persona can verify the new one
  1362. self.assertTrue(persona.verify(nident))
  1363. orig_open = open
  1364. with mock.patch('os.path.expanduser', side_effect=expandusermock) \
  1365. as eu, mock.patch('medashare.cli.open') as op:
  1366. # that when the store fails
  1367. def open_repl(fname, mode):
  1368. #print('or:', repr(fname), repr(mode), file=sys.stderr)
  1369. self.assertIn(mode, ('rb', 'wb'))
  1370. if fname == identfname or mode == 'wb':
  1371. return orig_open(fname, mode)
  1372. #print('foo:', repr(fname), repr(mode), file=sys.stderr)
  1373. raise FileNotFoundError
  1374. op.side_effect = open_repl
  1375. # and there is no store
  1376. with mock.patch('sys.stderr', io.StringIO()) as stderr, mock.patch('sys.argv', [ 'progname', 'list', 'foo', ]) as argv:
  1377. # that it exits
  1378. with self.assertRaises(SystemExit) as cm:
  1379. main()
  1380. # with error code 1
  1381. self.assertEqual(cm.exception.code, 1)
  1382. # and outputs an error message
  1383. self.assertEqual(stderr.getvalue(),
  1384. 'ERROR: file not found: \'foo\'\n')