helpers.py 15 KB

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