helpers.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. import argparse
  2. import binascii
  3. import grp
  4. import msgpack
  5. import os
  6. import pwd
  7. import re
  8. import stat
  9. import sys
  10. import time
  11. from datetime import datetime, timezone
  12. from fnmatch import translate
  13. from operator import attrgetter
  14. import fcntl
  15. class Error(Exception):
  16. """Error base class"""
  17. exit_code = 1
  18. def get_message(self):
  19. return 'Error: ' + type(self).__doc__.format(*self.args)
  20. class UpgradableLock:
  21. class LockUpgradeFailed(Error):
  22. """Failed to acquire write lock on {}"""
  23. def __init__(self, path, exclusive=False):
  24. self.path = path
  25. try:
  26. self.fd = open(path, 'r+')
  27. except IOError:
  28. self.fd = open(path, 'r')
  29. if exclusive:
  30. fcntl.lockf(self.fd, fcntl.LOCK_EX)
  31. else:
  32. fcntl.lockf(self.fd, fcntl.LOCK_SH)
  33. self.is_exclusive = exclusive
  34. def upgrade(self):
  35. try:
  36. fcntl.lockf(self.fd, fcntl.LOCK_EX)
  37. except OSError as e:
  38. raise self.LockUpgradeFailed(self.path)
  39. self.is_exclusive = True
  40. def release(self):
  41. fcntl.lockf(self.fd, fcntl.LOCK_UN)
  42. self.fd.close()
  43. class Manifest:
  44. MANIFEST_ID = b'\0' * 32
  45. def __init__(self):
  46. self.archives = {}
  47. self.config = {}
  48. @classmethod
  49. def load(cls, repository):
  50. from .key import key_factory
  51. manifest = cls()
  52. manifest.repository = repository
  53. cdata = repository.get(manifest.MANIFEST_ID)
  54. manifest.key = key = key_factory(repository, cdata)
  55. data = key.decrypt(None, cdata)
  56. manifest.id = key.id_hash(data)
  57. m = msgpack.unpackb(data)
  58. if not m.get(b'version') == 1:
  59. raise ValueError('Invalid manifest version')
  60. manifest.archives = dict((k.decode('utf-8'), v) for k,v in m[b'archives'].items())
  61. manifest.timestamp = m.get(b'timestamp')
  62. if manifest.timestamp:
  63. manifest.timestamp = manifest.timestamp.decode('ascii')
  64. manifest.config = m[b'config']
  65. return manifest, key
  66. def write(self):
  67. self.timestamp = datetime.utcnow().isoformat()
  68. data = msgpack.packb({
  69. 'version': 1,
  70. 'archives': self.archives,
  71. 'timestamp': self.timestamp,
  72. 'config': self.config,
  73. })
  74. self.id = self.key.id_hash(data)
  75. self.repository.put(self.MANIFEST_ID, self.key.encrypt(data))
  76. def prune_split(archives, pattern, n, skip=[]):
  77. last = None
  78. keep = []
  79. if n == 0:
  80. return keep
  81. for a in sorted(archives, key=attrgetter('ts'), reverse=True):
  82. period = a.ts.strftime(pattern)
  83. if period != last:
  84. last = period
  85. if a not in skip:
  86. keep.append(a)
  87. if len(keep) == n: break
  88. return keep
  89. class Statistics:
  90. def __init__(self):
  91. self.osize = self.csize = self.usize = self.nfiles = 0
  92. def update(self, size, csize, unique):
  93. self.osize += size
  94. self.csize += csize
  95. if unique:
  96. self.usize += csize
  97. def print_(self):
  98. print('Number of files: %d' % self.nfiles)
  99. print('Original size: %d (%s)' % (self.osize, format_file_size(self.osize)))
  100. print('Compressed size: %s (%s)' % (self.csize, format_file_size(self.csize)))
  101. print('Unique data: %d (%s)' % (self.usize, format_file_size(self.usize)))
  102. def get_keys_dir():
  103. """Determine where to repository keys and cache"""
  104. return os.environ.get('ATTIC_KEYS_DIR',
  105. os.path.join(os.path.expanduser('~'), '.attic', 'keys'))
  106. def get_cache_dir():
  107. """Determine where to repository keys and cache"""
  108. return os.environ.get('ATTIC_CACHE_DIR',
  109. os.path.join(os.path.expanduser('~'), '.cache', 'attic'))
  110. def to_localtime(ts):
  111. """Convert datetime object from UTC to local time zone"""
  112. return datetime(*time.localtime((ts - datetime(1970, 1, 1, tzinfo=timezone.utc)).total_seconds())[:6])
  113. def adjust_patterns(paths, excludes):
  114. if paths:
  115. return (excludes or []) + [IncludePattern(path) for path in paths] + [ExcludePattern('*')]
  116. else:
  117. return excludes
  118. def exclude_path(path, patterns):
  119. """Used by create and extract sub-commands to determine
  120. whether or not an item should be processed.
  121. """
  122. for pattern in (patterns or []):
  123. if pattern.match(path):
  124. return isinstance(pattern, ExcludePattern)
  125. return False
  126. # For both IncludePattern and ExcludePattern, we require that
  127. # the pattern either match the whole path or an initial segment
  128. # of the path up to but not including a path separator. To
  129. # unify the two cases, we add a path separator to the end of
  130. # the path before matching.
  131. class IncludePattern:
  132. """Literal files or directories listed on the command line
  133. for some operations (e.g. extract, but not create).
  134. If a directory is specified, all paths that start with that
  135. path match as well. A trailing slash makes no difference.
  136. """
  137. def __init__(self, pattern):
  138. self.pattern = pattern.rstrip(os.path.sep)+os.path.sep
  139. def match(self, path):
  140. return (path+os.path.sep).startswith(self.pattern)
  141. def __repr__(self):
  142. return '%s(%s)' % (type(self), self.pattern)
  143. class ExcludePattern(IncludePattern):
  144. """Shell glob patterns to exclude. A trailing slash means to
  145. exclude the contents of a directory, but not the directory itself.
  146. """
  147. def __init__(self, pattern):
  148. if pattern.endswith(os.path.sep):
  149. self.pattern = pattern+'*'+os.path.sep
  150. else:
  151. self.pattern = pattern+os.path.sep+'*'
  152. # fnmatch and re.match both cache compiled regular expressions.
  153. # Nevertheless, this is about 10 times faster.
  154. self.regex = re.compile(translate(self.pattern))
  155. def match(self, path):
  156. return self.regex.match(path+os.path.sep) is not None
  157. def __repr__(self):
  158. return '%s(%s)' % (type(self), self.pattern)
  159. def walk_path(path, skip_inodes=None):
  160. st = os.lstat(path)
  161. if skip_inodes and (st.st_ino, st.st_dev) in skip_inodes:
  162. return
  163. yield path, st
  164. if stat.S_ISDIR(st.st_mode):
  165. for f in os.listdir(path):
  166. for x in walk_path(os.path.join(path, f), skip_inodes):
  167. yield x
  168. def format_time(t):
  169. """Format datetime suitable for fixed length list output
  170. """
  171. if (datetime.now() - t).days < 365:
  172. return t.strftime('%b %d %H:%M')
  173. else:
  174. return t.strftime('%b %d %Y')
  175. def format_timedelta(td):
  176. """Format timedelta in a human friendly format
  177. """
  178. # Since td.total_seconds() requires python 2.7
  179. ts = (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10 ** 6) / float(10 ** 6)
  180. s = ts % 60
  181. m = int(ts / 60) % 60
  182. h = int(ts / 3600) % 24
  183. txt = '%.2f seconds' % s
  184. if m:
  185. txt = '%d minutes %s' % (m, txt)
  186. if h:
  187. txt = '%d hours %s' % (h, txt)
  188. if td.days:
  189. txt = '%d days %s' % (td.days, txt)
  190. return txt
  191. def format_file_mode(mod):
  192. """Format file mode bits for list output
  193. """
  194. def x(v):
  195. return ''.join(v & m and s or '-'
  196. for m, s in ((4, 'r'), (2, 'w'), (1, 'x')))
  197. return '%s%s%s' % (x(mod // 64), x(mod // 8), x(mod))
  198. def format_file_size(v):
  199. """Format file size into a human friendly format
  200. """
  201. if v > 1024 * 1024 * 1024:
  202. return '%.2f GB' % (v / 1024. / 1024. / 1024.)
  203. elif v > 1024 * 1024:
  204. return '%.2f MB' % (v / 1024. / 1024.)
  205. elif v > 1024:
  206. return '%.2f kB' % (v / 1024.)
  207. else:
  208. return '%d B' % v
  209. class IntegrityError(Exception):
  210. """
  211. """
  212. def memoize(function):
  213. cache = {}
  214. def decorated_function(*args):
  215. try:
  216. return cache[args]
  217. except KeyError:
  218. val = function(*args)
  219. cache[args] = val
  220. return val
  221. return decorated_function
  222. @memoize
  223. def uid2user(uid):
  224. try:
  225. return pwd.getpwuid(uid).pw_name
  226. except KeyError:
  227. return None
  228. @memoize
  229. def user2uid(user):
  230. try:
  231. return user and pwd.getpwnam(user).pw_uid
  232. except KeyError:
  233. return None
  234. @memoize
  235. def gid2group(gid):
  236. try:
  237. return grp.getgrgid(gid).gr_name
  238. except KeyError:
  239. return None
  240. @memoize
  241. def group2gid(group):
  242. try:
  243. return group and grp.getgrnam(group).gr_gid
  244. except KeyError:
  245. return None
  246. class Location:
  247. """Object representing a repository / archive location
  248. """
  249. proto = user = host = port = path = archive = None
  250. ssh_re = re.compile(r'(?P<proto>ssh)://(?:(?P<user>[^@]+)@)?'
  251. r'(?P<host>[^:/#]+)(?::(?P<port>\d+))?'
  252. r'(?P<path>[^:]+)(?:::(?P<archive>.+))?')
  253. file_re = re.compile(r'(?P<proto>file)://'
  254. r'(?P<path>[^:]+)(?:::(?P<archive>.+))?')
  255. scp_re = re.compile(r'((?:(?P<user>[^@]+)@)?(?P<host>[^:/]+):)?'
  256. r'(?P<path>[^:]+)(?:::(?P<archive>.+))?')
  257. def __init__(self, text):
  258. self.orig = text
  259. if not self.parse(text):
  260. raise ValueError
  261. def parse(self, text):
  262. m = self.ssh_re.match(text)
  263. if m:
  264. self.proto = m.group('proto')
  265. self.user = m.group('user')
  266. self.host = m.group('host')
  267. self.port = m.group('port') and int(m.group('port')) or None
  268. self.path = m.group('path')
  269. self.archive = m.group('archive')
  270. return True
  271. m = self.file_re.match(text)
  272. if m:
  273. self.proto = m.group('proto')
  274. self.path = m.group('path')
  275. self.archive = m.group('archive')
  276. return True
  277. m = self.scp_re.match(text)
  278. if m:
  279. self.user = m.group('user')
  280. self.host = m.group('host')
  281. self.path = m.group('path')
  282. self.archive = m.group('archive')
  283. self.proto = self.host and 'ssh' or 'file'
  284. return True
  285. return False
  286. def __str__(self):
  287. items = []
  288. items.append('proto=%r' % self.proto)
  289. items.append('user=%r' % self.user)
  290. items.append('host=%r' % self.host)
  291. items.append('port=%r' % self.port)
  292. items.append('path=%r' % self.path)
  293. items.append('archive=%r' % self.archive)
  294. return ', '.join(items)
  295. def to_key_filename(self):
  296. name = re.sub('[^\w]', '_', self.path).strip('_')
  297. if self.proto != 'file':
  298. name = self.host + '__' + name
  299. return os.path.join(get_keys_dir(), name)
  300. def __repr__(self):
  301. return "Location(%s)" % self
  302. def location_validator(archive=None):
  303. def validator(text):
  304. try:
  305. loc = Location(text)
  306. except ValueError:
  307. raise argparse.ArgumentTypeError('Invalid location format: "%s"' % text)
  308. if archive is True and not loc.archive:
  309. raise argparse.ArgumentTypeError('"%s": No archive specified' % text)
  310. elif archive is False and loc.archive:
  311. raise argparse.ArgumentTypeError('"%s" No archive can be specified' % text)
  312. return loc
  313. return validator
  314. def read_msgpack(filename):
  315. with open(filename, 'rb') as fd:
  316. return msgpack.unpack(fd)
  317. def write_msgpack(filename, d):
  318. with open(filename + '.tmp', 'wb') as fd:
  319. msgpack.pack(d, fd)
  320. fd.flush()
  321. os.fsync(fd)
  322. os.rename(filename + '.tmp', filename)
  323. def decode_dict(d, keys, encoding='utf-8', errors='surrogateescape'):
  324. for key in keys:
  325. if isinstance(d.get(key), bytes):
  326. d[key] = d[key].decode(encoding, errors)
  327. return d
  328. def remove_surrogates(s, errors='replace'):
  329. """Replace surrogates generated by fsdecode with '?'
  330. """
  331. return s.encode('utf-8', errors).decode('utf-8')
  332. _safe_re = re.compile('^((..)?/+)+')
  333. def make_path_safe(path):
  334. """Make path safe by making it relative and local
  335. """
  336. return _safe_re.sub('', path) or '.'
  337. def daemonize():
  338. """Detach process from controlling terminal and run in background
  339. """
  340. pid = os.fork()
  341. if pid:
  342. os._exit(0)
  343. os.setsid()
  344. pid = os.fork()
  345. if pid:
  346. os._exit(0)
  347. os.chdir('/')
  348. os.close(0)
  349. os.close(1)
  350. os.close(2)
  351. fd = os.open('/dev/null', os.O_RDWR)
  352. os.dup2(fd, 0)
  353. os.dup2(fd, 1)
  354. os.dup2(fd, 2)
  355. def is_a_terminal(fd):
  356. """Determine if `fd` is associated with a terminal or not
  357. """
  358. try:
  359. os.ttyname(fd.fileno())
  360. return True
  361. except:
  362. return False
  363. if sys.version < '3.3':
  364. # st_mtime_ns attribute only available in 3.3+
  365. def st_mtime_ns(st):
  366. return int(st.st_mtime * 1e9)
  367. # unhexlify in < 3.3 incorrectly only accepts bytes input
  368. def unhexlify(data):
  369. if isinstance(data, str):
  370. data = data.encode('ascii')
  371. return binascii.unhexlify(data)
  372. else:
  373. def st_mtime_ns(st):
  374. return st.st_mtime_ns
  375. unhexlify = binascii.unhexlify