2017-04-09 16:20:04 -07:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
|
|
|
|
# by Ben Fisher, https://github.com/downpoured
|
|
|
|
|
# a Python script to restore files from Duplicati
|
|
|
|
|
# similar to Duplicati.RecoveryTool, but with no dependencies on Mono/.NET
|
|
|
|
|
# uses streaming apis to restore a large number of files and use limited RAM.
|
|
|
|
|
# supports backups using AES encryption (.aes) or No Encryption (.zip),
|
|
|
|
|
# if data uses GPG/other encryption, decrypt files to .zip before running this tool.
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import io
|
|
|
|
|
import json
|
|
|
|
|
import ijson
|
|
|
|
|
import sqlite3
|
|
|
|
|
import zipfile
|
|
|
|
|
import codecs
|
|
|
|
|
import getpass
|
|
|
|
|
import fnmatch
|
|
|
|
|
import base64
|
|
|
|
|
import hashlib
|
|
|
|
|
from collections import OrderedDict
|
|
|
|
|
from pyaescrypt import pyAesCryptDecrypt, fail_with_msg
|
|
|
|
|
|
|
|
|
|
# increase for faster restores, at the cost of higher RAM usage.
|
|
|
|
|
maxCacheSizeInMB = 100
|
|
|
|
|
|
2017-04-09 17:46:20 -07:00
|
|
|
def mainRestore(d, outdir, passw, scope):
|
2017-04-09 16:20:04 -07:00
|
|
|
# locate dlist
|
2017-04-09 17:46:20 -07:00
|
|
|
dlists = [name for name in os.listdir(d) if (name.endswith('.dlist.zip') or
|
2017-04-09 16:20:04 -07:00
|
|
|
name.endswith('.dlist.zip.aes'))]
|
|
|
|
|
|
|
|
|
|
if dlists:
|
|
|
|
|
dlist = sorted(dlists, reverse=True)[0]
|
|
|
|
|
print('using %s which looks like the most recent dlist.' % dlist)
|
|
|
|
|
|
|
|
|
|
# decrypt dlist file to disk
|
|
|
|
|
if dlist.endswith('.dlist.zip.aes'):
|
2017-04-09 17:46:20 -07:00
|
|
|
with open(os.path.join(d, 'py-restore-dlist-decr.zip'), 'wb') as f:
|
|
|
|
|
pyAesCryptDecrypt(os.path.join(d, dlist), passw, f.write)
|
|
|
|
|
dlist = os.path.join(d, 'py-restore-dlist-decr.zip')
|
2017-04-09 16:20:04 -07:00
|
|
|
else:
|
|
|
|
|
fail_with_msg('No .dlist.zip files found.')
|
|
|
|
|
|
|
|
|
|
# create cache
|
2017-04-09 17:46:20 -07:00
|
|
|
largestDBlock = max(os.path.getsize(os.path.join(d, name))
|
|
|
|
|
for name in os.listdir(d) if '.dblock.zip' in name)
|
2017-04-09 16:20:04 -07:00
|
|
|
amountInCache = max(1, (maxCacheSizeInMB * 1024 * 1024) // largestDBlock)
|
|
|
|
|
cacheDecrypted = MemoizeDecorator(pyAesCryptDecrypt, amountInCache)
|
|
|
|
|
|
|
|
|
|
# read some metadata from the manifest
|
2017-04-09 17:46:20 -07:00
|
|
|
db, numberToName = createDb(d, 'py-restore-index.sqlite', passw, cacheDecrypted)
|
2017-04-09 16:20:04 -07:00
|
|
|
dbopts = (db, numberToName, cacheDecrypted, passw)
|
2017-04-09 17:46:20 -07:00
|
|
|
opts = getArchiveOptions(d, dlist)
|
2017-04-09 16:20:04 -07:00
|
|
|
|
|
|
|
|
# restore files
|
|
|
|
|
i = 0
|
|
|
|
|
msgs = 0
|
|
|
|
|
print('Restoring files...')
|
2017-04-09 17:46:20 -07:00
|
|
|
for item in enumerateDlistFiles(d, dlist):
|
2017-04-09 16:20:04 -07:00
|
|
|
if item['type'] == 'File' and fnmatch.fnmatch(item['path'], scope):
|
|
|
|
|
# print a dot every 10 files to show we're still working
|
|
|
|
|
i += 1
|
|
|
|
|
if i % 10 == 0:
|
|
|
|
|
sys.stdout.write('.')
|
|
|
|
|
sys.stdout.flush()
|
|
|
|
|
|
|
|
|
|
if item['path'].startswith('\\\\'):
|
|
|
|
|
# windows network share
|
|
|
|
|
outPath = outdir + item['path'][1:]
|
|
|
|
|
elif item['path'][1:2] == ':' and item['path'][2:3] == '\\':
|
|
|
|
|
# windows absolute path
|
|
|
|
|
outPath = outdir + '\\' + item['path'][0] + item['path'][2:]
|
|
|
|
|
else:
|
|
|
|
|
outPath = outdir + item['path']
|
|
|
|
|
|
|
|
|
|
try:
|
2017-04-09 17:46:20 -07:00
|
|
|
restoreOneFile(d, dbopts, opts, item, outPath)
|
2017-04-09 16:20:04 -07:00
|
|
|
except Exception as e:
|
|
|
|
|
msgs += 1
|
|
|
|
|
print(toAscii('\nWhen restoring %s to %s: %s' %
|
|
|
|
|
(item['path'], outPath, str(e))))
|
|
|
|
|
elif item['type'] == 'Symlink':
|
|
|
|
|
print(toAscii('Symlink existed at ' + item['path']))
|
|
|
|
|
|
|
|
|
|
db.close()
|
|
|
|
|
print('\n\n%d warnings/errors seen.' % msgs)
|
|
|
|
|
|
2017-04-09 17:46:20 -07:00
|
|
|
def restoreOneFile(d, dbopts, opts, listEntry, outPath):
|
2017-04-09 16:20:04 -07:00
|
|
|
# create destination directory
|
|
|
|
|
if not os.path.isdir(os.path.split(outPath)[0]):
|
|
|
|
|
os.makedirs(os.path.split(outPath)[0])
|
|
|
|
|
|
|
|
|
|
# write to file
|
|
|
|
|
with open(outPath, 'wb') as f:
|
|
|
|
|
if 'blocklists' not in listEntry or not listEntry['blocklists']:
|
|
|
|
|
# small files store data in one block
|
2017-04-09 17:46:20 -07:00
|
|
|
data = getContentBlock(d, dbopts, listEntry['hash'])
|
2017-04-09 16:20:04 -07:00
|
|
|
f.write(data)
|
|
|
|
|
else:
|
|
|
|
|
# large files point to a list of blockids, each of which points
|
|
|
|
|
# to another list of blockids
|
|
|
|
|
for blhi, blh in enumerate(listEntry['blocklists']):
|
|
|
|
|
blockhashoffset = blhi * opts['hashes-per-block'] * opts['blocksize']
|
2017-04-09 17:46:20 -07:00
|
|
|
binaryHashes = getContentBlock(d, dbopts, blh)
|
2017-04-09 16:20:04 -07:00
|
|
|
for bi, start in enumerate(range(0, len(binaryHashes), opts['hash-size'])):
|
2017-04-09 17:46:20 -07:00
|
|
|
thehash = binaryHashes[start: start + opts['hash-size']]
|
|
|
|
|
thehash = base64.b64encode(thehash)
|
|
|
|
|
data = getContentBlock(d, dbopts, thehash)
|
2017-04-09 16:20:04 -07:00
|
|
|
f.seek(blockhashoffset + bi * opts['blocksize'])
|
|
|
|
|
f.write(data)
|
|
|
|
|
|
|
|
|
|
# verify file size
|
|
|
|
|
if listEntry['size'] != os.path.getsize(outPath):
|
|
|
|
|
raise Exception('Restored %s. expected filesize %d and got %d' %
|
|
|
|
|
(outPath, listEntry['size'], os.path.getsize(outPath)))
|
|
|
|
|
|
|
|
|
|
# verify file checksum
|
|
|
|
|
hasher = opts['file-hasher']()
|
|
|
|
|
computeHash(outPath, hasher)
|
|
|
|
|
expected = listEntry['hash'].encode('utf8')
|
|
|
|
|
got = base64.b64encode(hasher.digest())
|
|
|
|
|
if expected != got:
|
|
|
|
|
raise Exception('Restored %s. expected checksum %s and got %s' %
|
|
|
|
|
(outPath, expected, got))
|
|
|
|
|
|
2017-04-09 17:46:20 -07:00
|
|
|
def getContentBlock(d, dbopts, blockId):
|
2017-04-09 16:20:04 -07:00
|
|
|
if isinstance(blockId, bytes):
|
|
|
|
|
blockId = blockId.decode('utf8')
|
|
|
|
|
db, numberToName, cacheDecrypted, passw = dbopts
|
|
|
|
|
name = getFilenameFromBlockId(db, numberToName, blockId)
|
2017-04-09 17:46:20 -07:00
|
|
|
with openAsZipFile(d, name, passw, cacheDecrypted) as z:
|
|
|
|
|
with z.open(base64PlainToBase64Url(blockId), 'r') as zipContents:
|
2017-04-09 16:20:04 -07:00
|
|
|
return zipContents.read()
|
|
|
|
|
|
2017-04-09 17:46:20 -07:00
|
|
|
def openAsZipFile(d, name, passw, cacheDecrypted):
|
|
|
|
|
fullpath = os.path.join(d, name)
|
2017-04-09 16:20:04 -07:00
|
|
|
assertTrue(os.path.exists(fullpath), 'missing %s' % fullpath)
|
|
|
|
|
if name.endswith('.zip'):
|
|
|
|
|
return zipfile.ZipFile(fullpath, 'r')
|
|
|
|
|
else:
|
|
|
|
|
data = io.BytesIO(cacheDecrypted(fullpath, passw))
|
|
|
|
|
return zipfile.ZipFile(data, 'r')
|
|
|
|
|
|
2017-04-09 17:46:20 -07:00
|
|
|
def enumerateDlistFiles(d, dlist):
|
2017-04-09 16:20:04 -07:00
|
|
|
convertStreamToUtf8 = codecs.getreader('utf-8-sig')
|
2017-04-09 17:46:20 -07:00
|
|
|
with zipfile.ZipFile(os.path.join(d, dlist), 'r') as z:
|
|
|
|
|
with z.open('filelist.json', 'r') as zipentry:
|
2017-04-09 16:20:04 -07:00
|
|
|
with convertStreamToUtf8(zipentry) as zipentryutf8:
|
|
|
|
|
for item in streamJsonArrayItems(zipentryutf8):
|
|
|
|
|
yield item
|
|
|
|
|
|
|
|
|
|
def streamJsonArrayItems(f):
|
|
|
|
|
# read items from a json array -- without loading the entire file into memory
|
|
|
|
|
level = 0
|
|
|
|
|
currentObject = ijson.ObjectBuilder()
|
|
|
|
|
parsed = ijson.parse(f)
|
|
|
|
|
|
|
|
|
|
# eat the initial start_array event
|
|
|
|
|
assertEqual('start_array', next(parsed)[1])
|
|
|
|
|
|
|
|
|
|
# construct objects. use level in order to support objects within objects
|
2017-04-09 17:46:20 -07:00
|
|
|
for _, event, value in parsed:
|
2017-04-09 16:20:04 -07:00
|
|
|
currentObject.event(event, value)
|
|
|
|
|
if event == 'start_map':
|
|
|
|
|
level += 1
|
|
|
|
|
elif event == 'end_map':
|
|
|
|
|
level -= 1
|
|
|
|
|
if level == 0:
|
|
|
|
|
yield currentObject.value
|
|
|
|
|
currentObject = ijson.ObjectBuilder()
|
|
|
|
|
|
|
|
|
|
# ignore the final end_array event.
|
|
|
|
|
|
2017-04-09 17:46:20 -07:00
|
|
|
def createDb(d, filename, passw, cacheDecrypted):
|
2017-04-09 16:20:04 -07:00
|
|
|
# get a summary of the current dblocks
|
2017-04-09 17:46:20 -07:00
|
|
|
zipfilenames = [s for s in os.listdir(d) if
|
2017-04-09 16:20:04 -07:00
|
|
|
s.endswith('.dblock.zip') or s.endswith('.dblock.zip.aes')]
|
|
|
|
|
zipfilenames.sort()
|
|
|
|
|
filenamesAndSizes = ';'.join(zipfilenames)
|
|
|
|
|
filenamesAndSizes += ';'.join(map(str,
|
2017-04-09 17:46:20 -07:00
|
|
|
[os.path.getsize(os.path.join(d, s)) for s in zipfilenames]))
|
2017-04-09 16:20:04 -07:00
|
|
|
needNew = True
|
2017-04-09 17:46:20 -07:00
|
|
|
dbpath = os.path.join(d, filename)
|
2017-04-09 16:20:04 -07:00
|
|
|
if os.path.exists(dbpath):
|
|
|
|
|
# check that the dblocks we have match the dblocks this db has.
|
|
|
|
|
dbCheckIfComplete = sqlite3.connect(dbpath)
|
|
|
|
|
cursor = dbCheckIfComplete.cursor()
|
|
|
|
|
needNew = not cursor.execute('''SELECT FileNum FROM BlockIdToFile
|
|
|
|
|
WHERE BlockId=?''', [filenamesAndSizes.encode('utf8')]).fetchone()
|
|
|
|
|
cursor.close()
|
|
|
|
|
dbCheckIfComplete.close()
|
|
|
|
|
|
|
|
|
|
db = sqlite3.connect(dbpath)
|
|
|
|
|
cursor = db.cursor()
|
|
|
|
|
cursor.execute("PRAGMA temp_store = memory")
|
|
|
|
|
cursor.execute("PRAGMA page_size = 16384")
|
|
|
|
|
cursor.execute("PRAGMA cache_size = 1000")
|
|
|
|
|
cursor.close()
|
|
|
|
|
numberToName = OrderedDict((n + 1, v) for n, v in enumerate(zipfilenames))
|
|
|
|
|
if needNew:
|
|
|
|
|
print('Creating index, this may take some time...')
|
2017-04-09 17:46:20 -07:00
|
|
|
createBlockIdsToFilenames(d, db, passw, cacheDecrypted,
|
2017-04-09 16:20:04 -07:00
|
|
|
numberToName, filenamesAndSizes)
|
|
|
|
|
else:
|
|
|
|
|
print('Able to re-use existing index.')
|
|
|
|
|
|
|
|
|
|
return db, numberToName
|
|
|
|
|
|
2017-04-09 17:46:20 -07:00
|
|
|
def createBlockIdsToFilenames(d, db, passw, cache, numberToName, filenamesAndSizes):
|
2017-04-09 16:20:04 -07:00
|
|
|
# create an index mapping blockId to filename
|
|
|
|
|
with db:
|
|
|
|
|
c = db.cursor()
|
|
|
|
|
c.execute('''CREATE TABLE IF NOT EXISTS BlockIdToFile (
|
|
|
|
|
BlockId TEXT,
|
|
|
|
|
FileNum INTEGER)''')
|
|
|
|
|
c.execute('''CREATE INDEX IF NOT EXISTS IxBlockId ON BlockIdToFile(BlockId)''')
|
|
|
|
|
c.execute('''DELETE FROM BlockIdToFile WHERE 1''')
|
|
|
|
|
for num in numberToName:
|
|
|
|
|
name = numberToName[num]
|
|
|
|
|
sys.stdout.write('.')
|
|
|
|
|
sys.stdout.flush()
|
2017-04-09 17:46:20 -07:00
|
|
|
with openAsZipFile(d, name, passw, cache) as z:
|
|
|
|
|
for entryname in z.namelist():
|
2017-04-09 16:20:04 -07:00
|
|
|
entryname = base64UrlToBase64Plain(entryname)
|
|
|
|
|
c.execute('INSERT INTO BlockIdToFile (BlockId, FileNum) VALUES (?, ?)',
|
|
|
|
|
[entryname.encode('utf8'), num])
|
|
|
|
|
|
|
|
|
|
# write a summary of the current dblocks
|
|
|
|
|
c.execute('INSERT INTO BlockIdToFile (BlockId, FileNum) VALUES (?, ?)',
|
|
|
|
|
[filenamesAndSizes.encode('utf8'), -1])
|
|
|
|
|
c.close()
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
return numberToName
|
|
|
|
|
|
|
|
|
|
def base64PlainToBase64Url(data):
|
|
|
|
|
if isinstance(data, bytes): return data.replace(b'+', b'-').replace(b'/', b'_')
|
|
|
|
|
else: return data.replace('+', '-').replace('/', '_')
|
|
|
|
|
|
|
|
|
|
def base64UrlToBase64Plain(data):
|
|
|
|
|
if isinstance(data, bytes): return data.replace(b'-', b'+').replace(b'_', b'/')
|
|
|
|
|
else: return data.replace('-', '+').replace('_', '/')
|
|
|
|
|
|
|
|
|
|
def computeHash(path, hasher, buffersize=64 * 1024):
|
|
|
|
|
with open(path, 'rb') as f:
|
|
|
|
|
while True:
|
|
|
|
|
buffer = f.read(buffersize)
|
|
|
|
|
if not buffer:
|
|
|
|
|
break
|
|
|
|
|
hasher.update(buffer)
|
|
|
|
|
|
|
|
|
|
def getFilenameFromBlockId(db, numberToName, blockId):
|
|
|
|
|
c = db.cursor()
|
|
|
|
|
if isinstance(blockId, str):
|
|
|
|
|
blockId = blockId.encode('utf8')
|
|
|
|
|
rows = c.execute('SELECT FileNum FROM BlockIdToFile WHERE BlockId=?', [blockId])
|
|
|
|
|
for row in rows:
|
|
|
|
|
return numberToName[row[0]]
|
|
|
|
|
assertTrue(False, 'block id %s not found' % blockId)
|
|
|
|
|
c.close()
|
|
|
|
|
|
|
|
|
|
def toAscii(s):
|
|
|
|
|
import unicodedata
|
|
|
|
|
s = unicodedata.normalize('NFKD', str(s))
|
|
|
|
|
return s.encode('ascii', 'ignore').decode('ascii')
|
|
|
|
|
|
|
|
|
|
def assertEqual(v, expect, context=''):
|
|
|
|
|
if v != expect:
|
|
|
|
|
s = 'Not equal: ' + context + ' Expected ' + expect + ' but got ' + v
|
|
|
|
|
raise AssertionError(toAscii(s))
|
|
|
|
|
|
|
|
|
|
def assertTrue(condition, *context):
|
|
|
|
|
if not condition:
|
|
|
|
|
s = ' '.join(context) if context else ''
|
|
|
|
|
raise AssertionError(toAscii(s))
|
|
|
|
|
|
|
|
|
|
# code.activestate.com/recipes/496879-memoize-decorator-function-with-cache-size-limit/
|
|
|
|
|
def MemoizeDecorator(fn, cachesize):
|
|
|
|
|
cache = OrderedDict()
|
|
|
|
|
def memoize_wrapper(*args, **kwargs):
|
|
|
|
|
import pickle
|
|
|
|
|
key = pickle.dumps((args, kwargs))
|
|
|
|
|
try:
|
|
|
|
|
return cache[key]
|
|
|
|
|
except KeyError:
|
|
|
|
|
result = fn(*args, **kwargs)
|
|
|
|
|
cache[key] = result
|
|
|
|
|
if len(cache) > memoize_wrapper._limit:
|
|
|
|
|
# remove like in a FIFO queue
|
|
|
|
|
cache.popitem(False)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
memoize_wrapper._limit = cachesize
|
|
|
|
|
memoize_wrapper._cache = cache
|
|
|
|
|
return memoize_wrapper
|
|
|
|
|
|
|
|
|
|
def getHasherObject(hashalg):
|
|
|
|
|
hashalg = hashalg.lower()
|
2017-04-09 17:46:20 -07:00
|
|
|
if hashalg == 'sha1': return hashlib.sha1
|
|
|
|
|
elif hashalg == 'md5': return hashlib.md5
|
|
|
|
|
elif hashalg == 'sha256': return hashlib.sha256
|
|
|
|
|
elif hashalg == 'sha384': return hashlib.sha384
|
|
|
|
|
elif hashalg == 'sha512': return hashlib.sha512
|
2017-04-09 16:20:04 -07:00
|
|
|
else: assertTrue(False, 'unknown hash algorithm %s' % hashalg)
|
|
|
|
|
|
2017-04-09 17:46:20 -07:00
|
|
|
def getArchiveOptions(d, dlist):
|
2017-04-09 16:20:04 -07:00
|
|
|
opts = {}
|
|
|
|
|
convertStreamToUtf8 = codecs.getreader('utf-8-sig')
|
2017-04-09 17:46:20 -07:00
|
|
|
with zipfile.ZipFile(os.path.join(d, dlist), 'r') as z:
|
|
|
|
|
with z.open('manifest', 'r') as zipentry:
|
2017-04-09 16:20:04 -07:00
|
|
|
with convertStreamToUtf8(zipentry) as zipentryutf8:
|
|
|
|
|
alljson = zipentryutf8.read()
|
|
|
|
|
manifest = json.loads(alljson)
|
|
|
|
|
assertEqual(manifest['BlockHash'], manifest['FileHash'],
|
|
|
|
|
'script currently needs same hash method for blockhash and filehash')
|
|
|
|
|
opts['blocksize'] = int(manifest['Blocksize'])
|
|
|
|
|
opts['block-hasher'] = getHasherObject(manifest['BlockHash'])
|
|
|
|
|
opts['file-hasher'] = getHasherObject(manifest['FileHash'])
|
|
|
|
|
opts['hash-size'] = opts['block-hasher']().digest_size
|
|
|
|
|
opts['hashes-per-block'] = (opts['blocksize'] //
|
|
|
|
|
(opts['block-hasher']().digest_size))
|
|
|
|
|
return opts
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
print('Welcome to Python Duplicati recovery.')
|
2017-04-09 17:46:20 -07:00
|
|
|
d = input('Please type the full path to a directory with Duplicati\'s .aes or .zip files:')
|
|
|
|
|
assertTrue(os.path.isdir(d), 'Directory not found')
|
2017-04-09 16:20:04 -07:00
|
|
|
scope = input('Please type * to restore all files, or a pattern like /path/to/files/* to ' +
|
|
|
|
|
'restore the files in a certain directory)')
|
|
|
|
|
outdir = input('Please enter the path to an empty destination directory:')
|
|
|
|
|
assertTrue(os.path.isdir(outdir), 'Output directory not found')
|
|
|
|
|
assertTrue(len(os.listdir(outdir)) == 0, 'Output directory not empty')
|
|
|
|
|
if sys.platform.startswith('win') and len(outdir) > 40:
|
|
|
|
|
print('note: paths on windows have limited length, you might want to consider a shorter output path.')
|
|
|
|
|
|
|
|
|
|
# get password
|
|
|
|
|
passw = None
|
2017-04-09 17:46:20 -07:00
|
|
|
if any(name.endswith('.aes') for name in os.listdir(d)):
|
2017-04-09 16:20:04 -07:00
|
|
|
passw = str(getpass.getpass("Password:"))
|
|
|
|
|
|
2017-04-09 17:46:20 -07:00
|
|
|
mainRestore(d, outdir, passw, scope)
|
2017-04-09 16:20:04 -07:00
|
|
|
print('Complete.')
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
main()
|