file_transfer.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. import base64, hashlib, json, os, pathlib, sys, tempfile, time, uuid, fcntl, stat
  2. os.umask(0o077)
  3. CHUNK = 512 * 1024
  4. HARD = 100 * 1024 * 1024
  5. namespace = sys.argv[1]
  6. assert len(namespace) == 64 and all(c in '0123456789abcdef' for c in namespace), 'invalid namespace'
  7. root = pathlib.Path(tempfile.gettempdir()) / ('.yaoyao-transfers-' + namespace)
  8. root.mkdir(mode=0o700, exist_ok=True)
  9. assert not root.is_symlink() and root.stat().st_uid == os.getuid(), 'invalid transfer directory'
  10. action = json.load(sys.stdin)
  11. lock = open(root / '.lock', 'a+')
  12. fcntl.flock(lock, fcntl.LOCK_EX)
  13. def save(path, value):
  14. temporary = str(path) + '.tmp'
  15. with open(temporary, 'w') as stream:
  16. json.dump(value, stream); stream.flush(); os.fsync(stream.fileno())
  17. os.replace(temporary, path)
  18. def clean(path, state):
  19. temporary = state.get('temporary')
  20. if temporary:
  21. try: os.unlink(temporary)
  22. except FileNotFoundError: pass
  23. path.unlink(missing_ok=True)
  24. def sha_file(path):
  25. sha = hashlib.sha256()
  26. with open(path, 'rb') as stream:
  27. while True:
  28. data = stream.read(CHUNK)
  29. if not data: break
  30. sha.update(data)
  31. return sha.hexdigest()
  32. for old in root.glob('*.json'):
  33. try:
  34. state = json.loads(old.read_text())
  35. if state['expiresAt'] <= time.time() or action['op'] == 'transfer-cleanup': clean(old, state)
  36. except (ValueError, KeyError): pass
  37. if action['op'] == 'transfer-cleanup':
  38. print(json.dumps({'ok': True})); sys.exit(0)
  39. transfer_id = action['transferId']
  40. assert str(uuid.UUID(transfer_id)) == transfer_id, 'invalid transfer id'
  41. state_path = root / (transfer_id + '.json')
  42. state = json.loads(state_path.read_text()) if state_path.exists() else None
  43. op = action['op']
  44. if op == 'transfer-abort':
  45. if state: clean(state_path, state)
  46. result = {'ok': True}
  47. elif op in ('transfer-read-open', 'transfer-write-open'):
  48. limit = action['maxBytes']
  49. assert type(limit) is int and 1024 * 1024 <= limit <= HARD, 'invalid transfer limit'
  50. signature = json.dumps(action, sort_keys=True)
  51. if state:
  52. assert state['signature'] == signature, 'transfer id conflict'
  53. result = state['metadata']
  54. else:
  55. assert len(list(root.glob('*.json'))) < 64, 'too many transfers'
  56. path = pathlib.Path(action['path']).expanduser().absolute()
  57. assert '\x00' not in str(path), 'invalid file path'
  58. if op == 'transfer-read-open':
  59. temporary = root / (transfer_id + '.source')
  60. sha = hashlib.sha256(); size = 0
  61. fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
  62. try:
  63. info = os.fstat(fd)
  64. assert stat.S_ISREG(info.st_mode) and info.st_size <= limit, 'file exceeds transfer limit or is not a regular file'
  65. with os.fdopen(fd, 'rb', closefd=False) as source, open(temporary, 'xb') as target:
  66. while True:
  67. data = source.read(CHUNK)
  68. if not data: break
  69. size += len(data); assert size <= limit, 'file exceeds transfer limit'
  70. sha.update(data); target.write(data)
  71. except BaseException:
  72. temporary.unlink(missing_ok=True); raise
  73. finally: os.close(fd)
  74. metadata = {'path': action['path'], 'size': size, 'sha256': sha.hexdigest()}
  75. state = {'kind': 'read', 'temporary': str(temporary), 'metadata': metadata}
  76. else:
  77. size = action['size']; sha = action['sha256']
  78. assert type(size) is int and 0 <= size <= limit, 'file exceeds transfer limit'
  79. assert len(sha) == 64 and all(c in '0123456789abcdef' for c in sha), 'invalid digest'
  80. assert type(action['overwrite']) is bool, 'invalid overwrite option'
  81. path.parent.mkdir(parents=True, exist_ok=True)
  82. path = path.parent.resolve() / path.name
  83. temporary = path.parent / ('.yaoyao-transfer-' + transfer_id)
  84. with open(temporary, 'xb'): pass
  85. metadata = {'path': action['path'], 'size': size, 'sha256': sha}
  86. state = {'kind': 'write', 'temporary': str(temporary), 'file': str(path), 'overwrite': action['overwrite'], 'received': 0, 'metadata': metadata}
  87. state.update(signature=signature, expiresAt=time.time() + 600)
  88. save(state_path, state); result = metadata
  89. else:
  90. assert state, 'file transfer expired'
  91. metadata = state['metadata']
  92. if state['kind'] == 'committing':
  93. try:
  94. info = os.stat(state['file'], follow_symlinks=False)
  95. if info.st_ino == state['inode'] and info.st_dev == state['device'] and info.st_size == metadata['size'] and sha_file(state['file']) == metadata['sha256']:
  96. state['kind'] = 'complete'
  97. except FileNotFoundError: pass
  98. if op == 'transfer-status':
  99. result = dict(metadata, complete=state['kind'] == 'complete', received=state.get('received', 0))
  100. elif op == 'transfer-read':
  101. offset = action['offset']
  102. assert state['kind'] == 'read' and type(offset) is int and 0 <= offset <= metadata['size'], 'invalid read offset'
  103. length = min(CHUNK, metadata['size'] - offset)
  104. with open(state['temporary'], 'rb') as source: source.seek(offset); data = source.read(length)
  105. assert len(data) == length, 'incomplete source chunk'
  106. result = {'offset': offset, 'data': base64.b64encode(data).decode()}
  107. elif op == 'transfer-append':
  108. offset = action['offset']; encoded = action['data']
  109. assert state['kind'] == 'write' and type(offset) is int and offset >= 0, 'invalid write offset'
  110. assert type(encoded) is str and len(encoded) <= ((CHUNK + 2) // 3) * 4, 'chunk too large'
  111. data = base64.b64decode(encoded, validate=True)
  112. assert 0 < len(data) <= CHUNK and base64.b64encode(data).decode() == encoded and offset + len(data) <= metadata['size'], 'invalid chunk'
  113. with open(state['temporary'], 'r+b') as target:
  114. received = os.fstat(target.fileno()).st_size
  115. assert offset <= received, 'out of order chunk'
  116. target.seek(offset)
  117. if offset < received:
  118. assert target.read(len(data)) == data, 'conflicting repeated chunk'
  119. else: target.write(data); target.flush(); os.fsync(target.fileno())
  120. state['received'] = os.fstat(target.fileno()).st_size
  121. result = {'received': state['received']}
  122. elif op == 'transfer-finish':
  123. if state['kind'] != 'complete':
  124. assert state['kind'] == 'write' and state['received'] == metadata['size'], 'incomplete transfer'
  125. assert sha_file(state['temporary']) == metadata['sha256'], 'file digest mismatch'
  126. path = pathlib.Path(state['file'])
  127. assert path.parent.resolve() == path.parent, 'destination directory changed'
  128. info = os.stat(state['temporary']); state.update(kind='committing', inode=info.st_ino, device=info.st_dev)
  129. save(state_path, state)
  130. if state['overwrite']: os.replace(state['temporary'], path)
  131. else: os.link(state['temporary'], path)
  132. state['kind'] = 'complete'
  133. pathlib.Path(state['temporary']).unlink(missing_ok=True)
  134. result = dict(metadata, complete=True)
  135. else: raise ValueError('invalid transfer operation')
  136. state['expiresAt'] = time.time() + 600; save(state_path, state)
  137. print(json.dumps(result))