Compare f32c885 ... +249 ... e5f7121

No flags found

Use flags to group coverage reports by test type, project and/or folders.
Then setup custom commit statuses and notifications for each flag.

e.g., #unittest #integration

#production #enterprise

#frontend #backend

Learn more about Codecov Flags here.

Showing 11 of 75 files from the diff.
Newly tracked file
borg/keymanager.py created.
Other files not tracked by Codecov
README.rst has changed.
docs/changes.rst has changed.
.travis.yml has changed.
docs/support.rst has changed.
docs/usage.rst has changed.
.gitattributes has changed.
borg/fuse.py has changed.
docs/conf.py has changed.
docs/api.rst has changed.
docs/index.rst has changed.
AUTHORS has changed.
borg/chunker.pyx has changed.
docs/authors.rst has changed.
borg/__init__.py has changed.
tox.ini has changed.
borg/crypto.pyx has changed.
MANIFEST.in has changed.
docs/faq.rst has changed.
borg/_hashindex.c has changed.
Vagrantfile has changed.
setup.py has changed.

@@ -5,15 +5,17 @@
Loading
5 5
import sys
6 6
import textwrap
7 7
from hmac import HMAC, compare_digest
8 -
from hashlib import sha256, pbkdf2_hmac
8 +
from hashlib import sha256, sha512, pbkdf2_hmac
9 9
10 -
from .helpers import IntegrityError, get_keys_dir, Error, yes
10 +
import msgpack
11 +
12 +
from .helpers import StableDict, IntegrityError, get_keys_dir, get_security_dir, Error, yes, bin_to_hex
11 13
from .logger import create_logger
12 14
logger = create_logger()
13 15
14 16
from .crypto import AES, bytes_to_long, long_to_bytes, bytes_to_int, num_aes_blocks
15 -
from .compress import Compressor
16 -
import msgpack
17 +
from .crypto import hkdf_hmac_sha512
18 +
from .compress import Compressor, CNONE
17 19
18 20
PREFIX = b'\0' * 8
19 21
@@ -30,6 +32,10 @@
Loading
30 32
    """Unsupported payload type {}. A newer version is required to access this repository."""
31 33
32 34
35 +
class UnsupportedManifestError(Error):
36 +
    """Unsupported manifest envelope. A newer version is required to access this repository."""
37 +
38 +
33 39
class KeyfileNotFoundError(Error):
34 40
    """No key file for repository {} found in {}."""
35 41
@@ -38,6 +44,32 @@
Loading
38 44
    """No key entry found in the config of repository {}."""
39 45
40 46
47 +
class TAMRequiredError(IntegrityError):
48 +
    __doc__ = textwrap.dedent("""
49 +
    Manifest is unauthenticated, but it is required for this repository.
50 +
51 +
    This either means that you are under attack, or that you modified this repository
52 +
    with a Borg version older than 1.0.9 after TAM authentication was enabled.
53 +
54 +
    In the latter case, use "borg upgrade --tam --force '{}'" to re-authenticate the manifest.
55 +
    """).strip()
56 +
    traceback = False
57 +
58 +
59 +
class TAMInvalid(IntegrityError):
60 +
    __doc__ = IntegrityError.__doc__
61 +
    traceback = False
62 +
63 +
    def __init__(self):
64 +
        # Error message becomes: "Data integrity error: Manifest authentication did not verify"
65 +
        super().__init__('Manifest authentication did not verify')
66 +
67 +
68 +
class TAMUnsupportedSuiteError(IntegrityError):
69 +
    """Could not verify manifest: Unsupported suite {!r}; a newer version is needed."""
70 +
    traceback = False
71 +
72 +
41 73
def key_creator(repository, args):
42 74
    if args.encryption == 'keyfile':
43 75
        return KeyfileKey.create(repository, args)
@@ -63,6 +95,16 @@
Loading
63 95
        raise UnsupportedPayloadError(key_type)
64 96
65 97
98 +
def tam_required_file(repository):
99 +
    security_dir = get_security_dir(bin_to_hex(repository.id))
100 +
    return os.path.join(security_dir, 'tam_required')
101 +
102 +
103 +
def tam_required(repository):
104 +
    file = tam_required_file(repository)
105 +
    return os.path.isfile(file)
106 +
107 +
66 108
class KeyBase:
67 109
    TYPE = None  # override in subclasses
68 110
@@ -71,23 +113,90 @@
Loading
71 113
        self.repository = repository
72 114
        self.target = None  # key location file path / repo obj
73 115
        self.compressor = Compressor('none')
116 +
        self.tam_required = True
74 117
75 118
    def id_hash(self, data):
76 119
        """Return HMAC hash using the "id" HMAC key
77 120
        """
78 121
79 -
    def encrypt(self, data):
122 +
    def encrypt(self, data, none_compression=False):
80 123
        pass
81 124
82 125
    def decrypt(self, id, data):
83 126
        pass
84 127
128 +
    def _tam_key(self, salt, context):
129 +
        return hkdf_hmac_sha512(
130 +
            ikm=self.id_key + self.enc_key + self.enc_hmac_key,
131 +
            salt=salt,
132 +
            info=b'borg-metadata-authentication-' + context,
133 +
            output_length=64
134 +
        )
135 +
136 +
    def pack_and_authenticate_metadata(self, metadata_dict, context=b'manifest'):
137 +
        metadata_dict = StableDict(metadata_dict)
138 +
        tam = metadata_dict['tam'] = StableDict({
139 +
            'type': 'HKDF_HMAC_SHA512',
140 +
            'hmac': bytes(64),
141 +
            'salt': os.urandom(64),
142 +
        })
143 +
        packed = msgpack.packb(metadata_dict, unicode_errors='surrogateescape')
144 +
        tam_key = self._tam_key(tam['salt'], context)
145 +
        tam['hmac'] = HMAC(tam_key, packed, sha512).digest()
146 +
        return msgpack.packb(metadata_dict, unicode_errors='surrogateescape')
147 +
148 +
    def unpack_and_verify_manifest(self, data, force_tam_not_required=False):
149 +
        """Unpack msgpacked *data* and return (object, did_verify)."""
150 +
        if data.startswith(b'\xc1' * 4):
151 +
            # This is a manifest from the future, we can't read it.
152 +
            raise UnsupportedManifestError()
153 +
        tam_required = self.tam_required
154 +
        if force_tam_not_required and tam_required:
155 +
            logger.warning('Manifest authentication DISABLED.')
156 +
            tam_required = False
157 +
        data = bytearray(data)
158 +
        # Since we don't trust these bytes we use the slower Python unpacker,
159 +
        # which is assumed to have a lower probability of security issues.
160 +
        unpacked = msgpack.fallback.unpackb(data, object_hook=StableDict, unicode_errors='surrogateescape')
161 +
        if b'tam' not in unpacked:
162 +
            if tam_required:
163 +
                raise TAMRequiredError(self.repository._location.canonical_path())
164 +
            else:
165 +
                logger.debug('TAM not found and not required')
166 +
                return unpacked, False
167 +
        tam = unpacked.pop(b'tam', None)
168 +
        if not isinstance(tam, dict):
169 +
            raise TAMInvalid()
170 +
        tam_type = tam.get(b'type', b'<none>').decode('ascii', 'replace')
171 +
        if tam_type != 'HKDF_HMAC_SHA512':
172 +
            if tam_required:
173 +
                raise TAMUnsupportedSuiteError(repr(tam_type))
174 +
            else:
175 +
                logger.debug('Ignoring TAM made with unsupported suite, since TAM is not required: %r', tam_type)
176 +
                return unpacked, False
177 +
        tam_hmac = tam.get(b'hmac')
178 +
        tam_salt = tam.get(b'salt')
179 +
        if not isinstance(tam_salt, bytes) or not isinstance(tam_hmac, bytes):
180 +
            raise TAMInvalid()
181 +
        offset = data.index(tam_hmac)
182 +
        data[offset:offset + 64] = bytes(64)
183 +
        tam_key = self._tam_key(tam_salt, context=b'manifest')
184 +
        calculated_hmac = HMAC(tam_key, data, sha512).digest()
185 +
        if not compare_digest(calculated_hmac, tam_hmac):
186 +
            raise TAMInvalid()
187 +
        logger.debug('TAM-verified manifest')
188 +
        return unpacked, True
189 +
85 190
86 191
class PlaintextKey(KeyBase):
87 192
    TYPE = 0x02
88 193
89 194
    chunk_seed = 0
90 195
196 +
    def __init__(self, repository):
197 +
        super().__init__(repository)
198 +
        self.tam_required = False
199 +
91 200
    @classmethod
92 201
    def create(cls, repository, args):
93 202
        logger.info('Encryption NOT enabled.\nUse the "--encryption=repokey|keyfile" to enable encryption.')
@@ -100,17 +209,25 @@
Loading
100 209
    def id_hash(self, data):
101 210
        return sha256(data).digest()
102 211
103 -
    def encrypt(self, data):
104 -
        return b''.join([self.TYPE_STR, self.compressor.compress(data)])
212 +
    def encrypt(self, data, none_compression=False):
213 +
        if none_compression:
214 +
            compressed = CNONE().compress(data)
215 +
        else:
216 +
            compressed = self.compressor.compress(data)
217 +
        return b''.join([self.TYPE_STR, compressed])
105 218
106 219
    def decrypt(self, id, data):
107 220
        if data[0] != self.TYPE:
108 -
            raise IntegrityError('Invalid encryption envelope')
221 +
            id_str = bin_to_hex(id) if id is not None else '(unknown)'
222 +
            raise IntegrityError('Chunk %s: Invalid encryption envelope' % id_str)
109 223
        data = self.compressor.decompress(memoryview(data)[1:])
110 224
        if id and sha256(data).digest() != id:
111 -
            raise IntegrityError('Chunk id verification failed')
225 +
            raise IntegrityError('Chunk %s: id verification failed' % bin_to_hex(id))
112 226
        return data
113 227
228 +
    def _tam_key(self, salt, context):
229 +
        return salt + context
230 +
114 231
115 232
class AESKeyBase(KeyBase):
116 233
    """Common base class shared by KeyfileKey and PassphraseKey
@@ -132,8 +249,11 @@
Loading
132 249
        """
133 250
        return HMAC(self.id_key, data, sha256).digest()
134 251
135 -
    def encrypt(self, data):
136 -
        data = self.compressor.compress(data)
252 +
    def encrypt(self, data, none_compression=False):
253 +
        if none_compression:
254 +
            data = CNONE().compress(data)
255 +
        else:
256 +
            data = self.compressor.compress(data)
137 257
        self.enc_cipher.reset()
138 258
        data = b''.join((self.enc_cipher.iv[8:], self.enc_cipher.encrypt(data)))
139 259
        hmac = HMAC(self.enc_hmac_key, data, sha256).digest()
@@ -142,24 +262,26 @@
Loading
142 262
    def decrypt(self, id, data):
143 263
        if not (data[0] == self.TYPE or
144 264
            data[0] == PassphraseKey.TYPE and isinstance(self, RepoKey)):
145 -
            raise IntegrityError('Invalid encryption envelope')
265 +
            id_str = bin_to_hex(id) if id is not None else '(unknown)'
266 +
            raise IntegrityError('Chunk %s: Invalid encryption envelope' % id_str)
146 267
        hmac_given = memoryview(data)[1:33]
147 268
        hmac_computed = memoryview(HMAC(self.enc_hmac_key, memoryview(data)[33:], sha256).digest())
148 269
        if not compare_digest(hmac_computed, hmac_given):
149 -
            raise IntegrityError('Encryption envelope checksum mismatch')
270 +
            id_str = bin_to_hex(id) if id is not None else '(unknown)'
271 +
            raise IntegrityError('Chunk %s: Encryption envelope checksum mismatch' % id_str)
150 272
        self.dec_cipher.reset(iv=PREFIX + data[33:41])
151 273
        data = self.compressor.decompress(self.dec_cipher.decrypt(data[41:]))
152 274
        if id:
153 275
            hmac_given = id
154 276
            hmac_computed = HMAC(self.id_key, data, sha256).digest()
155 277
            if not compare_digest(hmac_computed, hmac_given):
156 -
                raise IntegrityError('Chunk id verification failed')
278 +
                raise IntegrityError('Chunk %s: Chunk id verification failed' % bin_to_hex(id))
157 279
        return data
158 280
159 281
    def extract_nonce(self, payload):
160 282
        if not (payload[0] == self.TYPE or
161 283
            payload[0] == PassphraseKey.TYPE and isinstance(self, RepoKey)):
162 -
            raise IntegrityError('Invalid encryption envelope')
284 +
            raise IntegrityError('Manifest: Invalid encryption envelope')
163 285
        nonce = bytes_to_long(payload[33:41])
164 286
        return nonce
165 287
@@ -190,8 +312,9 @@
Loading
190 312
191 313
    @classmethod
192 314
    def verification(cls, passphrase):
193 -
        if yes('Do you want your passphrase to be displayed for verification? [yN]: ',
194 -
               env_var_override='BORG_DISPLAY_PASSPHRASE'):
315 +
        msg = 'Do you want your passphrase to be displayed for verification? [yN]: '
316 +
        if yes(msg, retry_msg=msg, invalid_msg='Invalid answer, try again.',
317 +
               retry=True, env_var_override='BORG_DISPLAY_PASSPHRASE'):
195 318
            print('Your passphrase (between double-quotes): "%s"' % passphrase,
196 319
                  file=sys.stderr)
197 320
            print('Make sure the passphrase displayed above is exactly what you wanted.',
@@ -200,7 +323,7 @@
Loading
200 323
                passphrase.encode('ascii')
201 324
            except UnicodeEncodeError:
202 325
                print('Your passphrase (UTF-8 encoding in hex): %s' %
203 -
                      hexlify(passphrase.encode('utf-8')).decode('ascii'),
326 +
                      bin_to_hex(passphrase.encode('utf-8')),
204 327
                      file=sys.stderr)
205 328
                print('As you have a non-ASCII passphrase, it is recommended to keep the UTF-8 encoding in hex together with the passphrase at a safe place.',
206 329
                      file=sys.stderr)
@@ -265,6 +388,7 @@
Loading
265 388
                key.decrypt(None, manifest_data)
266 389
                num_blocks = num_aes_blocks(len(manifest_data) - 41)
267 390
                key.init_ciphers(PREFIX + long_to_bytes(key.extract_nonce(manifest_data) + num_blocks))
391 +
                key._passphrase = passphrase
268 392
                return key
269 393
            except IntegrityError:
270 394
                passphrase = Passphrase.getpass(prompt)
@@ -280,6 +404,7 @@
Loading
280 404
    def init(self, repository, passphrase):
281 405
        self.init_from_random_data(passphrase.kdf(repository.id, self.iterations, 100))
282 406
        self.init_ciphers()
407 +
        self.tam_required = False
283 408
284 409
285 410
class KeyfileKeyBase(AESKeyBase):
@@ -303,6 +428,7 @@
Loading
303 428
                raise PassphraseWrong
304 429
        num_blocks = num_aes_blocks(len(manifest_data) - 41)
305 430
        key.init_ciphers(PREFIX + long_to_bytes(key.extract_nonce(manifest_data) + num_blocks))
431 +
        key._passphrase = passphrase
306 432
        return key
307 433
308 434
    def find_key(self):
@@ -323,6 +449,7 @@
Loading
323 449
            self.enc_hmac_key = key[b'enc_hmac_key']
324 450
            self.id_key = key[b'id_key']
325 451
            self.chunk_seed = key[b'chunk_seed']
452 +
            self.tam_required = key.get(b'tam_required', tam_required(self.repository))
326 453
            return True
327 454
        return False
328 455
@@ -359,15 +486,16 @@
Loading
359 486
            'enc_hmac_key': self.enc_hmac_key,
360 487
            'id_key': self.id_key,
361 488
            'chunk_seed': self.chunk_seed,
489 +
            'tam_required': self.tam_required,
362 490
        }
363 491
        data = self.encrypt_key_file(msgpack.packb(key), passphrase)
364 492
        key_data = '\n'.join(textwrap.wrap(b2a_base64(data).decode('ascii')))
365 493
        return key_data
366 494
367 -
    def change_passphrase(self):
368 -
        passphrase = Passphrase.new(allow_empty=True)
495 +
    def change_passphrase(self, passphrase=None):
496 +
        if passphrase is None:
497 +
            passphrase = Passphrase.new(allow_empty=True)
369 498
        self.save(self.target, passphrase)
370 -
        logger.info('Key updated')
371 499
372 500
    @classmethod
373 501
    def create(cls, repository, args):
@@ -426,7 +554,7 @@
Loading
426 554
    def save(self, target, passphrase):
427 555
        key_data = self._save(passphrase)
428 556
        with open(target, 'w') as fd:
429 -
            fd.write('%s %s\n' % (self.FILE_ID, hexlify(self.repository_id).decode('ascii')))
557 +
            fd.write('%s %s\n' % (self.FILE_ID, bin_to_hex(self.repository_id)))
430 558
            fd.write(key_data)
431 559
            fd.write('\n')
432 560
        self.target = target
@@ -433 +561 @@
Loading

@@ -1,6 +1,5 @@
Loading
1 -
from binascii import hexlify
2 1
from contextlib import contextmanager
3 -
from datetime import datetime, timezone
2 +
from datetime import datetime, timezone, timedelta
4 3
from getpass import getuser
5 4
from itertools import groupby
6 5
import errno
@@ -17,10 +16,10 @@
Loading
17 16
import time
18 17
from io import BytesIO
19 18
from . import xattr
20 -
from .helpers import Error, uid2user, user2uid, gid2group, group2gid, \
19 +
from .helpers import Error, uid2user, user2uid, gid2group, group2gid, bin_to_hex, \
21 20
    parse_timestamp, to_localtime, format_time, format_timedelta, remove_surrogates, \
22 21
    Manifest, Statistics, decode_dict, make_path_safe, StableDict, int_to_bigint, bigint_to_int, \
23 -
    ProgressIndicatorPercent
22 +
    ProgressIndicatorPercent, IntegrityError
24 23
from .platform import acl_get, acl_set
25 24
from .chunker import Chunker
26 25
from .hashindex import ChunkIndex
@@ -186,8 +185,8 @@
Loading
186 185
        """Failed to encode filename "{}" into file system encoding "{}". Consider configuring the LANG environment variable."""
187 186
188 187
    def __init__(self, repository, key, manifest, name, cache=None, create=False,
189 -
                 checkpoint_interval=300, numeric_owner=False, progress=False,
190 -
                 chunker_params=CHUNKER_PARAMS, start=None, end=None):
188 +
                 checkpoint_interval=300, numeric_owner=False, noatime=False, noctime=False, progress=False,
189 +
                 chunker_params=CHUNKER_PARAMS, start=None, start_monotonic=None, end=None):
191 190
        self.cwd = os.getcwd()
192 191
        self.key = key
193 192
        self.repository = repository
@@ -199,9 +198,14 @@
Loading
199 198
        self.name = name
200 199
        self.checkpoint_interval = checkpoint_interval
201 200
        self.numeric_owner = numeric_owner
201 +
        self.noatime = noatime
202 +
        self.noctime = noctime
203 +
        assert (start is None) == (start_monotonic is None), 'Logic error: if start is given, start_monotonic must be given as well and vice versa.'
202 204
        if start is None:
203 205
            start = datetime.utcnow()
206 +
            start_monotonic = time.monotonic()
204 207
        self.start = start
208 +
        self.start_monotonic = start_monotonic
205 209
        if end is None:
206 210
            end = datetime.utcnow()
207 211
        self.end = end
@@ -211,7 +215,7 @@
Loading
211 215
            self.chunker = Chunker(self.key.chunk_seed, *chunker_params)
212 216
            if name in manifest.archives:
213 217
                raise self.AlreadyExists(name)
214 -
            self.last_checkpoint = time.time()
218 +
            self.last_checkpoint = time.monotonic()
215 219
            i = 0
216 220
            while True:
217 221
                self.checkpoint_name = '%s.checkpoint%s' % (name, i and ('.%d' % i) or '')
@@ -227,7 +231,7 @@
Loading
227 231
228 232
    def _load_meta(self, id):
229 233
        data = self.key.decrypt(id, self.repository.get(id))
230 -
        metadata = msgpack.unpackb(data)
234 +
        metadata = msgpack.unpackb(data, unicode_errors='surrogateescape')
231 235
        if metadata[b'version'] != 1:
232 236
            raise Exception('Unknown archive metadata version')
233 237
        return metadata
@@ -254,7 +258,7 @@
Loading
254 258
255 259
    @property
256 260
    def fpr(self):
257 -
        return hexlify(self.id).decode('ascii')
261 +
        return bin_to_hex(self.id)
258 262
259 263
    @property
260 264
    def duration(self):
@@ -286,9 +290,9 @@
Loading
286 290
        if self.show_progress:
287 291
            self.stats.show_progress(item=item, dt=0.2)
288 292
        self.items_buffer.add(item)
289 -
        if time.time() - self.last_checkpoint > self.checkpoint_interval:
293 +
        if time.monotonic() - self.last_checkpoint > self.checkpoint_interval:
290 294
            self.write_checkpoint()
291 -
            self.last_checkpoint = time.time()
295 +
            self.last_checkpoint = time.monotonic()
292 296
293 297
    def write_checkpoint(self):
294 298
        self.save(self.checkpoint_name)
@@ -300,14 +304,17 @@
Loading
300 304
        if name in self.manifest.archives:
301 305
            raise self.AlreadyExists(name)
302 306
        self.items_buffer.flush(flush=True)
307 +
        duration = timedelta(seconds=time.monotonic() - self.start_monotonic)
303 308
        if timestamp is None:
304 309
            self.end = datetime.utcnow()
310 +
            self.start = self.end - duration
305 311
            start = self.start
306 312
            end = self.end
307 313
        else:
308 314
            self.end = timestamp
309 -
            start = timestamp
310 -
            end = timestamp  # we only have 1 value
315 +
            self.start = timestamp - duration
316 +
            end = timestamp
317 +
            start = self.start
311 318
        metadata = StableDict({
312 319
            'version': 1,
313 320
            'name': name,
@@ -318,7 +325,7 @@
Loading
318 325
            'time': start.isoformat(),
319 326
            'time_end': end.isoformat(),
320 327
        })
321 -
        data = msgpack.packb(metadata, unicode_errors='surrogateescape')
328 +
        data = self.key.pack_and_authenticate_metadata(metadata, context=b'archive')
322 329
        self.id = self.key.id_hash(data)
323 330
        self.cache.add_chunk(self.id, data, self.stats)
324 331
        self.manifest.archives[name] = {'id': self.id, 'time': metadata['time']}
@@ -522,7 +529,7 @@
Loading
522 529
            try:
523 530
                self.cache.chunk_decref(id, stats)
524 531
            except KeyError:
525 -
                cid = hexlify(id).decode('ascii')
532 +
                cid = bin_to_hex(id)
526 533
                raise ChunksIndexError(cid)
527 534
            except Repository.ObjectNotFound as e:
528 535
                # object not in repo - strange, but we wanted to delete it anyway.
@@ -572,10 +579,15 @@
Loading
572 579
            b'mode': st.st_mode,
573 580
            b'uid': st.st_uid, b'user': uid2user(st.st_uid),
574 581
            b'gid': st.st_gid, b'group': gid2group(st.st_gid),
575 -
            b'atime': int_to_bigint(st.st_atime_ns),
576 -
            b'ctime': int_to_bigint(st.st_ctime_ns),
577 582
            b'mtime': int_to_bigint(st.st_mtime_ns),
578 583
        }
584 +
        # borg can work with archives only having mtime (older attic archives do not have
585 +
        # atime/ctime). it can be useful to omit atime/ctime, if they change without the
586 +
        # file content changing - e.g. to get better metadata deduplication.
587 +
        if not self.noatime:
588 +
            item[b'atime'] = int_to_bigint(st.st_atime_ns)
589 +
        if not self.noctime:
590 +
            item[b'ctime'] = int_to_bigint(st.st_ctime_ns)
579 591
        if self.numeric_owner:
580 592
            item[b'user'] = item[b'group'] = None
581 593
        with backup_io():
@@ -610,7 +622,8 @@
Loading
610 622
            return 'b'  # block device
611 623
612 624
    def process_symlink(self, path, st):
613 -
        source = os.readlink(path)
625 +
        with backup_io():
626 +
            source = os.readlink(path)
614 627
        item = {b'path': make_path_safe(path), b'source': source}
615 628
        item.update(self.stat_attrs(st, path))
616 629
        self.add_item(item)
@@ -641,14 +654,12 @@
Loading
641 654
        # Is it a hard link?
642 655
        if st.st_nlink > 1:
643 656
            source = self.hard_links.get((st.st_ino, st.st_dev))
644 -
            if (st.st_ino, st.st_dev) in self.hard_links:
657 +
            if source is not None:
645 658
                item = self.stat_attrs(st, path)
646 659
                item.update({b'path': safe_path, b'source': source})
647 660
                self.add_item(item)
648 661
                status = 'h'  # regular file, hardlink (to already seen inodes)
649 662
                return status
650 -
            else:
651 -
                self.hard_links[st.st_ino, st.st_dev] = safe_path
652 663
        is_special_file = is_special(st.st_mode)
653 664
        if not is_special_file:
654 665
            path_hash = self.key.id_hash(os.path.join(self.cwd, path).encode('utf-8', 'surrogateescape'))
@@ -696,6 +707,9 @@
Loading
696 707
            item[b'mode'] = stat.S_IFREG | stat.S_IMODE(item[b'mode'])
697 708
        self.stats.nfiles += 1
698 709
        self.add_item(item)
710 +
        if st.st_nlink > 1 and source is None:
711 +
            # Add the hard link reference *after* the file has been added to the archive.
712 +
            self.hard_links[st.st_ino, st.st_dev] = safe_path
699 713
        return status
700 714
701 715
    @staticmethod
@@ -833,13 +847,22 @@
Loading
833 847
        self.repair = repair
834 848
        self.repository = repository
835 849
        self.init_chunks()
850 +
        if not self.chunks:
851 +
            logger.error('Repository contains no apparent data at all, cannot continue check/repair.')
852 +
            return False
836 853
        self.key = self.identify_key(repository)
837 854
        if Manifest.MANIFEST_ID not in self.chunks:
838 855
            logger.error("Repository manifest not found!")
839 856
            self.error_found = True
840 857
            self.manifest = self.rebuild_manifest()
841 858
        else:
842 -
            self.manifest, _ = Manifest.load(repository, key=self.key)
859 +
            try:
860 +
                self.manifest, _ = Manifest.load(repository, key=self.key)
861 +
            except IntegrityError as exc:
862 +
                logger.error('Repository manifest is corrupted: %s', exc)
863 +
                self.error_found = True
864 +
                del self.chunks[Manifest.MANIFEST_ID]
865 +
                self.manifest = self.rebuild_manifest()
843 866
        self.rebuild_refcounts(archive=archive, last=last, prefix=prefix)
844 867
        self.orphan_chunks_check()
845 868
        self.finish(save_space=save_space)
@@ -853,8 +876,9 @@
Loading
853 876
        """Fetch a list of all object keys from repository
854 877
        """
855 878
        # Explicitly set the initial hash table capacity to avoid performance issues
856 -
        # due to hash table "resonance"
857 -
        capacity = int(len(self.repository) * 1.35 + 1)  # > len * 1.0 / HASH_MAX_LOAD (see _hashindex.c)
879 +
        # due to hash table "resonance".
880 +
        # Since reconstruction of archive items can add some new chunks, add 10 % headroom
881 +
        capacity = int(len(self.repository) / ChunkIndex.MAX_LOAD_FACTOR * 1.1)
858 882
        self.chunks = ChunkIndex(capacity)
859 883
        marker = None
860 884
        while True:
@@ -895,7 +919,12 @@
Loading
895 919
        archive_keys_serialized = [msgpack.packb(name) for name in ARCHIVE_KEYS]
896 920
        for chunk_id, _ in self.chunks.iteritems():
897 921
            cdata = self.repository.get(chunk_id)
898 -
            data = self.key.decrypt(chunk_id, cdata)
922 +
            try:
923 +
                data = self.key.decrypt(chunk_id, cdata)
924 +
            except IntegrityError as exc:
925 +
                logger.error('Skipping corrupted chunk: %s', exc)
926 +
                self.error_found = True
927 +
                continue
899 928
            if not valid_msgpacked_dict(data, archive_keys_serialized):
900 929
                continue
901 930
            if b'cmdline' not in data or b'\xa7version\x01' not in data:
@@ -907,8 +936,18 @@
Loading
907 936
            except (TypeError, ValueError, StopIteration):
908 937
                continue
909 938
            if valid_archive(archive):
910 -
                logger.info('Found archive %s', archive[b'name'].decode('utf-8'))
911 -
                manifest.archives[archive[b'name'].decode('utf-8')] = {b'id': chunk_id, b'time': archive[b'time']}
939 +
                name = archive[b'name'].decode()
940 +
                logger.info('Found archive %s', name)
941 +
                if name in manifest.archives:
942 +
                    i = 1
943 +
                    while True:
944 +
                        new_name = '%s.%d' % (name, i)
945 +
                        if new_name not in manifest.archives:
946 +
                            break
947 +
                        i += 1
948 +
                    logger.warning('Duplicate archive name %s, storing as %s', name, new_name)
949 +
                    name = new_name
950 +
                manifest.archives[name] = {b'id': chunk_id, b'time': archive[b'time']}
912 951
        logger.info('Manifest rebuild complete.')
913 952
        return manifest
914 953
@@ -1008,16 +1047,26 @@
Loading
1008 1047
                return _state
1009 1048
1010 1049
            def report(msg, chunk_id, chunk_no):
1011 -
                cid = hexlify(chunk_id).decode('ascii')
1050 +
                cid = bin_to_hex(chunk_id)
1012 1051
                msg += ' [chunk: %06d_%s]' % (chunk_no, cid)  # see debug-dump-archive-items
1013 1052
                self.error_found = True
1014 1053
                logger.error(msg)
1015 1054
1055 +
            def list_keys_safe(keys):
1056 +
                return ', '.join((k.decode() if isinstance(k, bytes) else str(k) for k in keys))
1057 +
1016 1058
            def valid_item(obj):
1017 1059
                if not isinstance(obj, StableDict):
1018 -
                    return False
1060 +
                    return False, 'not a dictionary'
1061 +
                # A bug in Attic up to and including release 0.13 added a (meaningless) b'acl' key to every item.
1062 +
                # We ignore it here, should it exist. See test_attic013_acl_bug for details.
1063 +
                obj.pop(b'acl', None)
1019 1064
                keys = set(obj)
1020 -
                return REQUIRED_ITEM_KEYS.issubset(keys) and keys.issubset(item_keys)
1065 +
                if not REQUIRED_ITEM_KEYS.issubset(keys):
1066 +
                    return False, 'missing required keys: ' + list_keys_safe(REQUIRED_ITEM_KEYS - keys)
1067 +
                if not keys.issubset(item_keys):
1068 +
                    return False, 'invalid keys: ' + list_keys_safe(keys - item_keys)
1069 +
                return True, ''
1021 1070
1022 1071
            i = 0
1023 1072
            for state, items in groupby(archive[b'items'], missing_chunk_detector):
@@ -1033,10 +1082,11 @@
Loading
1033 1082
                    unpacker.feed(self.key.decrypt(chunk_id, cdata))
1034 1083
                    try:
1035 1084
                        for item in unpacker:
1036 -
                            if valid_item(item):
1085 +
                            valid, reason = valid_item(item)
1086 +
                            if valid:
1037 1087
                                yield item
1038 1088
                            else:
1039 -
                                report('Did not get expected metadata dict when unpacking item metadata', chunk_id, i)
1089 +
                                report('Did not get expected metadata dict when unpacking item metadata (%s)' % reason, chunk_id, i)
1040 1090
                    except RobustUnpacker.UnpackerCrashed as err:
1041 1091
                        report('Unpacker crashed while unpacking item metadata, trying to resync...', chunk_id, i)
1042 1092
                        unpacker.resync()
@@ -1051,13 +1101,21 @@
Loading
1051 1101
                                   key=lambda name_info: name_info[1][b'time'])
1052 1102
            if prefix is not None:
1053 1103
                archive_items = [item for item in archive_items if item[0].startswith(prefix)]
1104 +
                if not archive_items:
1105 +
                    logger.warning('--prefix %s does not match any archives', prefix)
1054 1106
            num_archives = len(archive_items)
1055 1107
            end = None if last is None else min(num_archives, last)
1108 +
            if last is not None and end < last:
1109 +
                logger.warning('--last %d archives: only found %d archives', last, end)
1056 1110
        else:
1057 1111
            # we only want one specific archive
1058 1112
            archive_items = [item for item in self.manifest.archives.items() if item[0] == archive]
1059 1113
            num_archives = 1
1060 1114
            end = 1
1115 +
            if not archive_items:
1116 +
                logger.error('Archive %s does not exist', archive)
1117 +
                self.error_found = True
1118 +
                return
1061 1119
1062 1120
        with cache_if_remote(self.repository) as repository:
1063 1121
            for i, (name, info) in enumerate(archive_items[:end]):
@@ -1064 +1122 @@
Loading

@@ -1,5 +1,7 @@
Loading
1 1
import argparse
2 +
from binascii import hexlify
2 3
from collections import namedtuple
4 +
import contextlib
3 5
from functools import wraps
4 6
import grp
5 7
import os
@@ -10,6 +12,7 @@
Loading
10 12
from shutil import get_terminal_size
11 13
import sys
12 14
import platform
15 +
import signal
13 16
import threading
14 17
import time
15 18
import unicodedata
@@ -25,6 +28,7 @@
Loading
25 28
from operator import attrgetter
26 29
27 30
from . import __version__ as borg_version
31 +
from . import __version_tuple__ as borg_version_tuple
28 32
from . import hashindex
29 33
from . import chunker
30 34
from . import crypto
@@ -51,17 +55,23 @@
Loading
51 55
    # show a traceback?
52 56
    traceback = False
53 57
58 +
    def __init__(self, *args):
59 +
        super().__init__(*args)
60 +
        self.args = args
61 +
54 62
    def get_message(self):
55 63
        return type(self).__doc__.format(*self.args)
56 64
65 +
    __str__ = get_message
66 +
57 67
58 68
class ErrorWithTraceback(Error):
59 69
    """like Error, but show a traceback also"""
60 70
    traceback = True
61 71
62 72
63 73
class IntegrityError(ErrorWithTraceback):
64 -
    """Data integrity error"""
74 +
    """Data integrity error: {}"""
65 75
66 76
67 77
class ExtensionModuleError(Error):
@@ -78,13 +88,13 @@
Loading
78 88
79 89
def check_extension_modules():
80 90
    from . import platform
81 -
    if hashindex.API_VERSION != 2:
91 +
    if hashindex.API_VERSION != '1.0_01':
82 92
        raise ExtensionModuleError
83 -
    if chunker.API_VERSION != 2:
93 +
    if chunker.API_VERSION != '1.0_01':
84 94
        raise ExtensionModuleError
85 -
    if crypto.API_VERSION != 2:
95 +
    if crypto.API_VERSION != '1.0_01':
86 96
        raise ExtensionModuleError
87 -
    if platform.API_VERSION != 2:
97 +
    if platform.API_VERSION != '1.0_01':
88 98
        raise ExtensionModuleError
89 99
90 100
@@ -99,10 +109,12 @@
Loading
99 109
        self.key = key
100 110
        self.repository = repository
101 111
        self.item_keys = frozenset(item_keys) if item_keys is not None else ITEM_KEYS
112 +
        self.tam_verified = False
113 +
        self.timestamp = None
102 114
103 115
    @classmethod
104 -
    def load(cls, repository, key=None):
105 -
        from .key import key_factory
116 +
    def load(cls, repository, key=None, force_tam_not_required=False):
117 +
        from .key import key_factory, tam_required_file, tam_required
106 118
        from .repository import Repository
107 119
        from .archive import ITEM_KEYS
108 120
        try:
@@ -113,8 +125,8 @@
Loading
113 125
            key = key_factory(repository, cdata)
114 126
        manifest = cls(key, repository)
115 127
        data = key.decrypt(None, cdata)
128 +
        m, manifest.tam_verified = key.unpack_and_verify_manifest(data, force_tam_not_required=force_tam_not_required)
116 129
        manifest.id = key.id_hash(data)
117 -
        m = msgpack.unpackb(data)
118 130
        if not m.get(b'version') == 1:
119 131
            raise ValueError('Invalid manifest version')
120 132
        manifest.archives = dict((k.decode('utf-8'), v) for k, v in m[b'archives'].items())
@@ -124,19 +136,40 @@
Loading
124 136
        manifest.config = m[b'config']
125 137
        # valid item keys are whatever is known in the repo or every key we know
126 138
        manifest.item_keys = frozenset(m.get(b'item_keys', [])) | ITEM_KEYS
139 +
140 +
        if manifest.tam_verified:
141 +
            manifest_required = manifest.config.get(b'tam_required', False)
142 +
            security_required = tam_required(repository)
143 +
            if manifest_required and not security_required:
144 +
                logger.debug('Manifest is TAM verified and says TAM is required, updating security database...')
145 +
                file = tam_required_file(repository)
146 +
                open(file, 'w').close()
147 +
            if not manifest_required and security_required:
148 +
                logger.debug('Manifest is TAM verified and says TAM is *not* required, updating security database...')
149 +
                os.unlink(tam_required_file(repository))
127 150
        return manifest, key
128 151
129 152
    def write(self):
130 -
        self.timestamp = datetime.utcnow().isoformat()
131 -
        data = msgpack.packb(StableDict({
153 +
        if self.key.tam_required:
154 +
            self.config[b'tam_required'] = True
155 +
        # self.timestamp needs to be strictly monotonically increasing. Clocks often are not set correctly
156 +
        if self.timestamp is None:
157 +
            self.timestamp = datetime.utcnow().isoformat()
158 +
        else:
159 +
            prev_ts = datetime.strptime(self.timestamp, "%Y-%m-%dT%H:%M:%S.%f")
160 +
            incremented = (prev_ts + timedelta(microseconds=1)).isoformat()
161 +
            self.timestamp = max(incremented, datetime.utcnow().isoformat())
162 +
        m = {
132 163
            'version': 1,
133 -
            'archives': self.archives,
164 +
            'archives': StableDict((name, StableDict(archive)) for name, archive in self.archives.items()),
134 165
            'timestamp': self.timestamp,
135 -
            'config': self.config,
136 -
            'item_keys': tuple(self.item_keys),
137 -
        }))
166 +
            'config': StableDict(self.config),
167 +
            'item_keys': tuple(sorted(self.item_keys)),
168 +
        }
169 +
        self.tam_verified = True
170 +
        data = self.key.pack_and_authenticate_metadata(m)
138 171
        self.id = self.key.id_hash(data)
139 -
        self.repository.put(self.MANIFEST_ID, self.key.encrypt(data))
172 +
        self.repository.put(self.MANIFEST_ID, self.key.encrypt(data, none_compression=True))
140 173
141 174
    def list_archive_infos(self, sort_by=None, reverse=False):
142 175
        # inexpensive Archive.list_archives replacement if we just need .name, .id, .ts
@@ -215,17 +248,21 @@
Loading
215 248
        return format_file_size(self.csize)
216 249
217 250
    def show_progress(self, item=None, final=False, stream=None, dt=None):
218 -
        now = time.time()
251 +
        now = time.monotonic()
219 252
        if dt is None or now - self.last_progress > dt:
220 253
            self.last_progress = now
221 254
            columns, lines = get_terminal_size()
222 255
            if not final:
223 256
                msg = '{0.osize_fmt} O {0.csize_fmt} C {0.usize_fmt} D {0.nfiles} N '.format(self)
224 257
                path = remove_surrogates(item[b'path']) if item else ''
225 258
                space = columns - len(msg)
226 -
                if space < len('...') + len(path):
227 -
                    path = '%s...%s' % (path[:(space // 2) - len('...')], path[-space // 2:])
228 -
                msg += "{0:<{space}}".format(path, space=space)
259 +
                if space < 12:
260 +
                    msg = ''
261 +
                    space = columns - len(msg)
262 +
                if space >= 8:
263 +
                    if space < len('...') + len(path):
264 +
                        path = '%s...%s' % (path[:(space // 2) - len('...')], path[-space // 2:])
265 +
                    msg += "{0:<{space}}".format(path, space=space)
229 266
            else:
230 267
                msg = ' ' * columns
231 268
            print(msg, file=stream or sys.stderr, end="\r", flush=True)
@@ -241,6 +278,18 @@
Loading
241 278
    return keys_dir
242 279
243 280
281 +
def get_security_dir(repository_id=None):
282 +
    """Determine where to store local security information."""
283 +
    xdg_config = os.environ.get('XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config'))
284 +
    security_dir = os.environ.get('BORG_SECURITY_DIR', os.path.join(xdg_config, 'borg', 'security'))
285 +
    if repository_id:
286 +
        security_dir = os.path.join(security_dir, repository_id)
287 +
    if not os.path.exists(security_dir):
288 +
        os.makedirs(security_dir)
289 +
        os.chmod(security_dir, stat.S_IRWXU)
290 +
    return security_dir
291 +
292 +
244 293
def get_cache_dir():
245 294
    """Determine where to repository keys and cache"""
246 295
    xdg_cache = os.environ.get('XDG_CACHE_HOME', os.path.join(os.path.expanduser('~'), '.cache'))
@@ -578,6 +627,9 @@
Loading
578 627
        'utcnow': current_time.utcnow(),
579 628
        'user': uid2user(os.getuid(), os.getuid()),
580 629
        'borgversion': borg_version,
630 +
        'borgmajor': '%d' % borg_version_tuple[:1],
631 +
        'borgminor': '%d.%d' % borg_version_tuple[:2],
632 +
        'borgpatch': '%d.%d.%d' % borg_version_tuple[:3],
581 633
    }
582 634
    return format_line(text, data)
583 635
@@ -660,6 +712,10 @@
Loading
660 712
    """
661 713
    provide a thread-local buffer
662 714
    """
715 +
716 +
    class MemoryLimitExceeded(Error, OSError):
717 +
        """Requested buffer size {} is above the limit of {}."""
718 +
663 719
    def __init__(self, allocator, size=4096, limit=None):
664 720
        """
665 721
        Initialize the buffer: use allocator(size) call to allocate a buffer.
@@ -679,11 +735,11 @@
Loading
679 735
        """
680 736
        resize the buffer - to avoid frequent reallocation, we usually always grow (if needed).
681 737
        giving init=True it is possible to first-time initialize or shrink the buffer.
682 -
        if a buffer size beyond the limit is requested, raise ValueError.
738 +
        if a buffer size beyond the limit is requested, raise Buffer.MemoryLimitExceeded (OSError).
683 739
        """
684 740
        size = int(size)
685 741
        if self.limit is not None and size > self.limit:
686 -
            raise ValueError('Requested buffer size %d is above the limit of %d.' % (size, self.limit))
742 +
            raise Buffer.MemoryLimitExceeded(size, self.limit)
687 743
        if init or len(self) < size:
688 744
            self._thread_local.buffer = self.allocator(size)
689 745
@@ -753,24 +809,74 @@
Loading
753 809
    return s.encode(coding, errors)
754 810
755 811
812 +
def bin_to_hex(binary):
813 +
    return hexlify(binary).decode('ascii')
814 +
815 +
756 816
class Location:
757 817
    """Object representing a repository / archive location
758 818
    """
759 819
    proto = user = host = port = path = archive = None
820 +
821 +
    # user must not contain "@", ":" or "/".
822 +
    # Quoting adduser error message:
823 +
    # "To avoid problems, the username should consist only of letters, digits,
824 +
    # underscores, periods, at signs and dashes, and not start with a dash
825 +
    # (as defined by IEEE Std 1003.1-2001)."
826 +
    # We use "@" as separator between username and hostname, so we must
827 +
    # disallow it within the pure username part.
828 +
    optional_user_re = r"""
829 +
        (?:(?P<user>[^@:/]+)@)?
830 +
    """
831 +
832 +
    # path must not contain :: (it ends at :: or string end), but may contain single colons.
833 +
    # to avoid ambiguities with other regexes, it must also not start with ":" nor with "//" nor with "ssh://".
834 +
    path_re = r"""
835 +
        (?!(:|//|ssh://))                                   # not starting with ":" or // or ssh://
836 +
        (?P<path>([^:]|(:(?!:)))+)                          # any chars, but no "::"
837 +
        """
838 +
    # abs_path must not contain :: (it ends at :: or string end), but may contain single colons.
839 +
    # it must start with a / and that slash is part of the path.
840 +
    abs_path_re = r"""
841 +
        (?P<path>(/([^:]|(:(?!:)))+))                       # start with /, then any chars, but no "::"
842 +
        """
843 +
844 +
    # optional ::archive_name at the end, archive name must not contain "/".
760 845
    # borg mount's FUSE filesystem creates one level of directories from
761 -
    # the archive names. Thus, we must not accept "/" in archive names.
762 -
    ssh_re = re.compile(r'(?P<proto>ssh)://(?:(?P<user>[^@]+)@)?'
763 -
                        r'(?P<host>[^:/#]+)(?::(?P<port>\d+))?'
764 -
                        r'(?P<path>[^:]+)(?:::(?P<archive>[^/]+))?$')
765 -
    file_re = re.compile(r'(?P<proto>file)://'
766 -
                         r'(?P<path>[^:]+)(?:::(?P<archive>[^/]+))?$')
767 -
    scp_re = re.compile(r'((?:(?P<user>[^@]+)@)?(?P<host>[^:/]+):)?'
768 -
                        r'(?P<path>[^:]+)(?:::(?P<archive>[^/]+))?$')
769 -
    # get the repo from BORG_RE env and the optional archive from param.
846 +
    # the archive names and of course "/" is not valid in a directory name.
847 +
    optional_archive_re = r"""
848 +
        (?:
849 +
            ::                                              # "::" as separator
850 +
            (?P<archive>[^/]+)                              # archive name must not contain "/"
851 +
        )?$"""                                              # must match until the end
852 +
853 +
    # regexes for misc. kinds of supported location specifiers:
854 +
    ssh_re = re.compile(r"""
855 +
        (?P<proto>ssh)://                                   # ssh://
856 +
        """ + optional_user_re + r"""                       # user@  (optional)
857 +
        (?P<host>[^:/]+)(?::(?P<port>\d+))?                 # host or host:port
858 +
        """ + abs_path_re + optional_archive_re, re.VERBOSE)  # path or path::archive
859 +
860 +
    file_re = re.compile(r"""
861 +
        (?P<proto>file)://                                  # file://
862 +
        """ + path_re + optional_archive_re, re.VERBOSE)    # path or path::archive
863 +
864 +
    # note: scp_re is also use for local pathes
865 +
    scp_re = re.compile(r"""
866 +
        (
867 +
            """ + optional_user_re + r"""                   # user@  (optional)
868 +
            (?P<host>[^:/]+):                               # host: (don't match / in host to disambiguate from file:)
869 +
        )?                                                  # user@host: part is optional
870 +
        """ + path_re + optional_archive_re, re.VERBOSE)    # path with optional archive
871 +
872 +
    # get the repo from BORG_REPO env and the optional archive from param.
770 873
    # if the syntax requires giving REPOSITORY (see "borg mount"),
771 874
    # use "::" to let it use the env var.
772 875
    # if REPOSITORY argument is optional, it'll automatically use the env.
773 -
    env_re = re.compile(r'(?:::(?P<archive>[^/]+)?)?$')
876 +
    env_re = re.compile(r"""                                # the repo part is fetched from BORG_REPO
877 +
        (?:::$)                                             # just "::" is ok (when a pos. arg is required, no archive)
878 +
        |                                                   # or
879 +
        """ + optional_archive_re, re.VERBOSE)              # archive name (optional, may be empty)
774 880
775 881
    def __init__(self, text=''):
776 882
        self.orig = text
@@ -795,26 +901,32 @@
Loading
795 901
        return True
796 902
797 903
    def _parse(self, text):
904 +
        def normpath_special(p):
905 +
            # avoid that normpath strips away our relative path hack and even makes p absolute
906 +
            relative = p.startswith('/./')
907 +
            p = os.path.normpath(p)
908 +
            return ('/.' + p) if relative else p
909 +
798 910
        m = self.ssh_re.match(text)
799 911
        if m:
800 912
            self.proto = m.group('proto')
801 913
            self.user = m.group('user')
802 914
            self.host = m.group('host')
803 915
            self.port = m.group('port') and int(m.group('port')) or None
804 -
            self.path = os.path.normpath(m.group('path'))
916 +
            self.path = normpath_special(m.group('path'))
805 917
            self.archive = m.group('archive')
806 918
            return True
807 919
        m = self.file_re.match(text)
808 920
        if m:
809 921
            self.proto = m.group('proto')
810 -
            self.path = os.path.normpath(m.group('path'))
922 +
            self.path = normpath_special(m.group('path'))
811 923
            self.archive = m.group('archive')
812 924
            return True
813 925
        m = self.scp_re.match(text)
814 926
        if m:
815 927
            self.user = m.group('user')
816 928
            self.host = m.group('host')
817 -
            self.path = os.path.normpath(m.group('path'))
929 +
            self.path = normpath_special(m.group('path'))
818 930
            self.archive = m.group('archive')
819 931
            self.proto = self.host and 'ssh' or 'file'
820 932
            return True
@@ -845,9 +957,9 @@
Loading
845 957
            return self.path
846 958
        else:
847 959
            if self.path and self.path.startswith('~'):
848 -
                path = '/' + self.path
960 +
                path = '/' + self.path  # /~/x = path x relative to home dir
849 961
            elif self.path and not self.path.startswith('/'):
850 -
                path = '/~/' + self.path
962 +
                path = '/./' + self.path  # /./x = path x relative to cwd
851 963
            else:
852 964
                path = self.path
853 965
            return 'ssh://{}{}{}{}'.format('{}@'.format(self.user) if self.user else '',
@@ -959,9 +1071,8 @@
Loading
959 1071
        default=False, retry=True, env_var_override=None, ofile=None, input=input):
960 1072
    """Output <msg> (usually a question) and let user input an answer.
961 1073
    Qualifies the answer according to falsish, truish and defaultish as True, False or <default>.
962 -
    If it didn't qualify and retry_msg is None (no retries wanted),
963 -
    return the default [which defaults to False]. Otherwise let user retry
964 -
    answering until answer is qualified.
1074 +
    If it didn't qualify and retry is False (no retries wanted), return the default [which
1075 +
    defaults to False]. If retry is True let user retry answering until answer is qualified.
965 1076
966 1077
    If env_var_override is given and this var is present in the environment, do not ask
967 1078
    the user, but just use the env var contents as answer as if it was typed in.
@@ -1156,3 +1267,49 @@
Loading
1156 1267
                except OSError:
1157 1268
                    pass
1158 1269
        return len(s)
1270 +
1271 +
1272 +
class SignalException(BaseException):
1273 +
    """base class for all signal-based exceptions"""
1274 +
1275 +
1276 +
class SigHup(SignalException):
1277 +
    """raised on SIGHUP signal"""
1278 +
1279 +
1280 +
class SigTerm(SignalException):
1281 +
    """raised on SIGTERM signal"""
1282 +
1283 +
1284 +
@contextlib.contextmanager
1285 +
def signal_handler(sig, handler):
1286 +
    """
1287 +
    when entering context, set up signal handler <handler> for signal <sig>.
1288 +
    when leaving context, restore original signal handler.
1289 +
1290 +
    <sig> can bei either a str when giving a signal.SIGXXX attribute name (it
1291 +
    won't crash if the attribute name does not exist as some names are platform
1292 +
    specific) or a int, when giving a signal number.
1293 +
1294 +
    <handler> is any handler value as accepted by the signal.signal(sig, handler).
1295 +
    """
1296 +
    if isinstance(sig, str):
1297 +
        sig = getattr(signal, sig, None)
1298 +
    if sig is not None:
1299 +
        orig_handler = signal.signal(sig, handler)
1300 +
    try:
1301 +
        yield
1302 +
    finally:
1303 +
        if sig is not None:
1304 +
            signal.signal(sig, orig_handler)
1305 +
1306 +
1307 +
def raising_signal_handler(exc_cls):
1308 +
    def handler(sig_no, frame):
1309 +
        # setting SIG_IGN avoids that an incoming second signal of this
1310 +
        # kind would raise a 2nd exception while we still process the
1311 +
        # exception handler for exc_cls for the 1st signal.
1312 +
        signal.signal(sig_no, signal.SIG_IGN)
1313 +
        raise exc_cls
1314 +
1315 +
    return handler
@@ -1159 +1316 @@
Loading

@@ -3,14 +3,14 @@
Loading
3 3
from collections import namedtuple
4 4
import os
5 5
import stat
6 -
from binascii import hexlify, unhexlify
6 +
from binascii import unhexlify
7 7
import shutil
8 8
9 9
from .key import PlaintextKey
10 10
from .logger import create_logger
11 11
logger = create_logger()
12 12
from .helpers import Error, get_cache_dir, decode_dict, int_to_bigint, \
13 -
    bigint_to_int, format_file_size, yes
13 +
    bigint_to_int, format_file_size, yes, bin_to_hex, Location
14 14
from .locking import Lock
15 15
from .hashindex import ChunkIndex
16 16
@@ -20,8 +20,11 @@
Loading
20 20
class Cache:
21 21
    """Client Side cache
22 22
    """
23 +
    class RepositoryIDNotUnique(Error):
24 +
        """Cache is newer than repository - do you have multiple, independently updated repos with same ID?"""
25 +
23 26
    class RepositoryReplay(Error):
24 -
        """Cache is newer than repository, refusing to continue"""
27 +
        """Cache is newer than repository - this is either an attack or unsafe (multiple repos with same ID)"""
25 28
26 29
    class CacheInitAbortedError(Error):
27 30
        """Cache initialization aborted"""
@@ -34,13 +37,13 @@
Loading
34 37
35 38
    @staticmethod
36 39
    def break_lock(repository, path=None):
37 -
        path = path or os.path.join(get_cache_dir(), hexlify(repository.id).decode('ascii'))
40 +
        path = path or os.path.join(get_cache_dir(), bin_to_hex(repository.id))
38 41
        Lock(os.path.join(path, 'lock'), exclusive=True).break_lock()
39 42
40 43
    @staticmethod
41 44
    def destroy(repository, path=None):
42 45
        """destroy the cache for ``repository`` or at ``path``"""
43 -
        path = path or os.path.join(get_cache_dir(), hexlify(repository.id).decode('ascii'))
46 +
        path = path or os.path.join(get_cache_dir(), bin_to_hex(repository.id))
44 47
        config = os.path.join(path, 'config')
45 48
        if os.path.exists(config):
46 49
            os.remove(config)  # kill config first
@@ -55,15 +58,16 @@
Loading
55 58
        self.repository = repository
56 59
        self.key = key
57 60
        self.manifest = manifest
58 -
        self.path = path or os.path.join(get_cache_dir(), hexlify(repository.id).decode('ascii'))
61 +
        self.path = path or os.path.join(get_cache_dir(), bin_to_hex(repository.id))
59 62
        self.do_files = do_files
60 63
        # Warn user before sending data to a never seen before unencrypted repository
61 64
        if not os.path.exists(self.path):
62 65
            if warn_if_unencrypted and isinstance(key, PlaintextKey):
63 66
                msg = ("Warning: Attempting to access a previously unknown unencrypted repository!" +
64 67
                       "\n" +
65 68
                       "Do you want to continue? [yN] ")
66 -
                if not yes(msg, false_msg="Aborting.", env_var_override='BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK'):
69 +
                if not yes(msg, false_msg="Aborting.", invalid_msg="Invalid answer, aborting.",
70 +
                           retry=False, env_var_override='BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK'):
67 71
                    raise self.CacheInitAbortedError()
68 72
            self.create()
69 73
        self.open(lock_wait=lock_wait)
@@ -73,13 +77,20 @@
Loading
73 77
                msg = ("Warning: The repository at location {} was previously located at {}".format(repository._location.canonical_path(), self.previous_location) +
74 78
                       "\n" +
75 79
                       "Do you want to continue? [yN] ")
76 -
                if not yes(msg, false_msg="Aborting.", env_var_override='BORG_RELOCATED_REPO_ACCESS_IS_OK'):
80 +
                if not yes(msg, false_msg="Aborting.", invalid_msg="Invalid answer, aborting.",
81 +
                           retry=False, env_var_override='BORG_RELOCATED_REPO_ACCESS_IS_OK'):
77 82
                    raise self.RepositoryAccessAborted()
83 +
                # adapt on-disk config immediately if the new location was accepted
84 +
                self.begin_txn()
85 +
                self.commit()
78 86
79 87
            if sync and self.manifest.id != self.manifest_id:
80 88
                # If repository is older than the cache something fishy is going on
81 89
                if self.timestamp and self.timestamp > manifest.timestamp:
82 -
                    raise self.RepositoryReplay()
90 +
                    if isinstance(key, PlaintextKey):
91 +
                        raise self.RepositoryIDNotUnique()
92 +
                    else:
93 +
                        raise self.RepositoryReplay()
83 94
                # Make sure an encrypted repository has not been swapped for an unencrypted repository
84 95
                if self.key_type is not None and self.key_type != str(key.TYPE):
85 96
                    raise self.EncryptionMethodMismatch()
@@ -120,7 +131,7 @@
Loading
120 131
        config = configparser.ConfigParser(interpolation=None)
121 132
        config.add_section('cache')
122 133
        config.set('cache', 'version', '1')
123 -
        config.set('cache', 'repository', hexlify(self.repository.id).decode('ascii'))
134 +
        config.set('cache', 'repository', bin_to_hex(self.repository.id))
124 135
        config.set('cache', 'manifest', '')
125 136
        with open(os.path.join(self.path, 'config'), 'w') as fd:
126 137
            config.write(fd)
@@ -129,10 +140,7 @@
Loading
129 140
        with open(os.path.join(self.path, 'files'), 'wb') as fd:
130 141
            pass  # empty file
131 142
132 -
    def _do_open(self):
133 -
        self.config = configparser.ConfigParser(interpolation=None)
134 -
        config_path = os.path.join(self.path, 'config')
135 -
        self.config.read(config_path)
143 +
    def _check_upgrade(self, config_path):
136 144
        try:
137 145
            cache_version = self.config.getint('cache', 'version')
138 146
            wanted_version = 1
@@ -143,6 +151,25 @@
Loading
143 151
        except configparser.NoSectionError:
144 152
            self.close()
145 153
            raise Exception('%s does not look like a Borg cache.' % config_path) from None
154 +
        # borg < 1.0.8rc1 had different canonicalization for the repo location (see #1655 and #1741).
155 +
        cache_loc = self.config.get('cache', 'previous_location', fallback=None)
156 +
        if cache_loc:
157 +
            repo_loc = self.repository._location.canonical_path()
158 +
            rl = Location(repo_loc)
159 +
            cl = Location(cache_loc)
160 +
            if cl.proto == rl.proto and cl.user == rl.user and cl.host == rl.host and cl.port == rl.port \
161 +
                    and \
162 +
                    cl.path and rl.path and \
163 +
                    cl.path.startswith('/~/') and rl.path.startswith('/./') and cl.path[3:] == rl.path[3:]:
164 +
                # everything is same except the expected change in relative path canonicalization,
165 +
                # update previous_location to avoid warning / user query about changed location:
166 +
                self.config.set('cache', 'previous_location', repo_loc)
167 +
168 +
    def _do_open(self):
169 +
        self.config = configparser.ConfigParser(interpolation=None)
170 +
        config_path = os.path.join(self.path, 'config')
171 +
        self.config.read(config_path)
172 +
        self._check_upgrade(config_path)
146 173
        self.id = self.config.get('cache', 'repository')
147 174
        self.manifest_id = unhexlify(self.config.get('cache', 'manifest'))
148 175
        self.timestamp = self.config.get('cache', 'timestamp', fallback=None)
@@ -164,7 +191,7 @@
Loading
164 191
165 192
    def _read_files(self):
166 193
        self.files = {}
167 -
        self._newest_mtime = 0
194 +
        self._newest_mtime = None
168 195
        logger.debug('Reading files cache ...')
169 196
        with open(os.path.join(self.path, 'files'), 'rb') as fd:
170 197
            u = msgpack.Unpacker(use_list=True)
@@ -195,6 +222,9 @@
Loading
195 222
        if not self.txn_active:
196 223
            return
197 224
        if self.files is not None:
225 +
            if self._newest_mtime is None:
226 +
                # was never set because no files were modified/added
227 +
                self._newest_mtime = 2 ** 63 - 1  # nanoseconds, good until y2262
198 228
            ttl = int(os.environ.get('BORG_FILES_CACHE_TTL', 20))
199 229
            with open(os.path.join(self.path, 'files'), 'wb') as fd:
200 230
                for path_hash, item in self.files.items():
@@ -206,7 +236,7 @@
Loading
206 236
                    if age == 0 and bigint_to_int(item[3]) < self._newest_mtime or \
207 237
                       age > 0 and age < ttl:
208 238
                        msgpack.pack((path_hash, item), fd)
209 -
        self.config.set('cache', 'manifest', hexlify(self.manifest.id).decode('ascii'))
239 +
        self.config.set('cache', 'manifest', bin_to_hex(self.manifest.id))
210 240
        self.config.set('cache', 'timestamp', self.manifest.timestamp)
211 241
        self.config.set('cache', 'key_type', str(self.key.TYPE))
212 242
        self.config.set('cache', 'previous_location', self.repository._location.canonical_path())
@@ -249,7 +279,7 @@
Loading
249 279
        archive_path = os.path.join(self.path, 'chunks.archive.d')
250 280
251 281
        def mkpath(id, suffix=''):
252 -
            id_hex = hexlify(id).decode('ascii')
282 +
            id_hex = bin_to_hex(id)
253 283
            path = os.path.join(archive_path, id_hex + suffix)
254 284
            return path.encode('utf-8')
255 285
@@ -424,4 +454,4 @@
Loading
424 454
        # Entry: Age, inode, size, mtime, chunk ids
425 455
        mtime_ns = st.st_mtime_ns
426 456
        self.files[path_hash] = msgpack.packb((0, st.st_ino, st.st_size, int_to_bigint(mtime_ns), ids))
427 -
        self._newest_mtime = max(self._newest_mtime, mtime_ns)
457 +
        self._newest_mtime = max(self._newest_mtime or 0, mtime_ns)
@@ -428 +458 @@
Loading

@@ -4,22 +4,47 @@
Loading
4 4
import os
5 5
import select
6 6
import shlex
7 -
from subprocess import Popen, PIPE
8 7
import sys
9 8
import tempfile
9 +
import textwrap
10 +
import time
11 +
from subprocess import Popen, PIPE
10 12
11 13
from . import __version__
12 14
13 15
from .helpers import Error, IntegrityError, sysinfo
14 16
from .helpers import replace_placeholders
17 +
from .helpers import bin_to_hex
15 18
from .repository import Repository
19 +
from .logger import create_logger
16 20
17 21
import msgpack
18 22
23 +
logger = create_logger(__name__)
24 +
19 25
RPC_PROTOCOL_VERSION = 2
20 26
21 27
BUFSIZE = 10 * 1024 * 1024
22 28
29 +
MAX_INFLIGHT = 100
30 +
31 +
32 +
def os_write(fd, data):
33 +
    """os.write wrapper so we do not lose data for partial writes."""
34 +
    # This is happening frequently on cygwin due to its small pipe buffer size of only 64kiB
35 +
    # and also due to its different blocking pipe behaviour compared to Linux/*BSD.
36 +
    # Neither Linux nor *BSD ever do partial writes on blocking pipes, unless interrupted by a
37 +
    # signal, in which case serve() would terminate.
38 +
    amount = remaining = len(data)
39 +
    while remaining:
40 +
        count = os.write(fd, data)
41 +
        remaining -= count
42 +
        if not remaining:
43 +
            break
44 +
        data = data[count:]
45 +
        time.sleep(count * 1e-09)
46 +
    return amount
47 +
23 48
24 49
class ConnectionClosed(Error):
25 50
    """Connection closed by remote host"""
@@ -37,6 +62,23 @@
Loading
37 62
    """RPC method {} is not valid"""
38 63
39 64
65 +
class UnexpectedRPCDataFormatFromClient(Error):
66 +
    """Borg {}: Got unexpected RPC data format from client."""
67 +
68 +
69 +
class UnexpectedRPCDataFormatFromServer(Error):
70 +
    """Got unexpected RPC data format from server:\n{}"""
71 +
72 +
    def __init__(self, data):
73 +
        try:
74 +
            data = data.decode()[:128]
75 +
        except UnicodeDecodeError:
76 +
            data = data[:128]
77 +
            data = ['%02X' % byte for byte in data]
78 +
            data = textwrap.fill(' '.join(data), 16 * 3)
79 +
        super().__init__(data)
80 +
81 +
40 82
class RepositoryServer:  # pragma: no cover
41 83
    rpc_methods = (
42 84
        '__len__',
@@ -79,13 +121,18 @@
Loading
79 121
            if r:
80 122
                data = os.read(stdin_fd, BUFSIZE)
81 123
                if not data:
82 -
                    self.repository.close()
124 +
                    if self.repository is not None:
125 +
                        self.repository.close()
126 +
                    else:
127 +
                        os_write(stderr_fd, "Borg {}: Got connection close before repository was opened.\n"
128 +
                                 .format(__version__).encode())
83 129
                    return
84 130
                unpacker.feed(data)
85 131
                for unpacked in unpacker:
86 132
                    if not (isinstance(unpacked, tuple) and len(unpacked) == 4):
87 -
                        self.repository.close()
88 -
                        raise Exception("Unexpected RPC data format.")
133 +
                        if self.repository is not None:
134 +
                            self.repository.close()
135 +
                        raise UnexpectedRPCDataFormatFromClient(__version__)
89 136
                    type, msgid, method, args = unpacked
90 137
                    method = method.decode('ascii')
91 138
                    try:
@@ -104,9 +151,9 @@
Loading
104 151
                            logging.exception('Borg %s: exception in RPC call:', __version__)
105 152
                            logging.error(sysinfo())
106 153
                        exc = "Remote Exception (see remote log for the traceback)"
107 -
                        os.write(stdout_fd, msgpack.packb((1, msgid, e.__class__.__name__, exc)))
154 +
                        os_write(stdout_fd, msgpack.packb((1, msgid, e.__class__.__name__, exc)))
108 155
                    else:
109 -
                        os.write(stdout_fd, msgpack.packb((1, msgid, None, res)))
156 +
                        os_write(stdout_fd, msgpack.packb((1, msgid, None, res)))
110 157
            if es:
111 158
                self.repository.close()
112 159
                return
@@ -116,8 +163,10 @@
Loading
116 163
117 164
    def open(self, path, create=False, lock_wait=None, lock=True, exclusive=None, append_only=False):
118 165
        path = os.fsdecode(path)
119 -
        if path.startswith('/~'):
166 +
        if path.startswith('/~'):  # /~/x = path x relative to home dir, /~username/x = relative to "user" home dir
120 167
            path = path[1:]
168 +
        elif path.startswith('/./'):  # /./x = path x relative to cwd
169 +
            path = path[3:]
121 170
        path = os.path.realpath(os.path.expanduser(path))
122 171
        if self.restrict_to_paths:
123 172
            # if --restrict-to-path P is given, we make sure that we only operate in/below path P.
@@ -168,6 +217,7 @@
Loading
168 217
            env.pop('LD_LIBRARY_PATH', None)
169 218
        env.pop('BORG_PASSPHRASE', None)  # security: do not give secrets to subprocess
170 219
        env['BORG_VERSION'] = __version__
220 +
        logger.debug('SSH command line: %s', borg_cmd)
171 221
        self.p = Popen(borg_cmd, bufsize=0, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env)
172 222
        self.stdin_fd = self.p.stdin.fileno()
173 223
        self.stdout_fd = self.p.stdout.fileno()
@@ -301,7 +351,6 @@
Loading
301 351
302 352
        calls = list(calls)
303 353
        waiting_for = []
304 -
        w_fds = [self.stdin_fd]
305 354
        while wait or calls:
306 355
            while waiting_for:
307 356
                try:
@@ -315,6 +364,10 @@
Loading
315 364
                            return
316 365
                except KeyError:
317 366
                    break
367 +
            if self.to_send or ((calls or self.preload_ids) and len(waiting_for) < MAX_INFLIGHT):
368 +
                w_fds = [self.stdin_fd]
369 +
            else:
370 +
                w_fds = []
318 371
            r, w, x = select.select(self.r_fds, w_fds, self.x_fds, 1)
319 372
            if x:
320 373
                raise Exception('FD exception occurred')
@@ -326,7 +379,7 @@
Loading
326 379
                    self.unpacker.feed(data)
327 380
                    for unpacked in self.unpacker:
328 381
                        if not (isinstance(unpacked, tuple) and len(unpacked) == 4):
329 -
                            raise Exception("Unexpected RPC data format.")
382 +
                            raise UnexpectedRPCDataFormatFromServer(data)
330 383
                        type, msgid, error, res = unpacked
331 384
                        if msgid in self.ignore_responses:
332 385
                            self.ignore_responses.remove(msgid)
@@ -347,7 +400,7 @@
Loading
347 400
                        else:
348 401
                            sys.stderr.write("Remote: " + line)
349 402
            if w:
350 -
                while not self.to_send and (calls or self.preload_ids) and len(waiting_for) < 100:
403 +
                while not self.to_send and (calls or self.preload_ids) and len(waiting_for) < MAX_INFLIGHT:
351 404
                    if calls:
352 405
                        if is_preloaded:
353 406
                            if calls[0] in self.cache:
@@ -374,8 +427,6 @@
Loading
374 427
                        # that the fd should be writable
375 428
                        if e.errno != errno.EAGAIN:
376 429
                            raise
377 -
                if not self.to_send and not (calls or self.preload_ids):
378 -
                    w_fds = []
379 430
        self.ignore_responses |= set(waiting_for)
380 431
381 432
    def check(self, repair=False, save_space=False):
@@ -382 +433 @@
Loading

@@ -1,5 +1,6 @@
Loading
1 1
import errno
2 2
import os
3 +
import subprocess
3 4
import sys
4 5
5 6
@@ -17,14 +18,19 @@
Loading
17 18
        os.close(fd)
18 19
19 20
21 +
# most POSIX platforms (but not Linux), see also borg 1.1 platform.base
22 +
def umount(mountpoint):
23 +
    return subprocess.call(['umount', mountpoint])
24 +
25 +
20 26
if sys.platform.startswith('linux'):  # pragma: linux only
21 -
    from .platform_linux import acl_get, acl_set, API_VERSION
27 +
    from .platform_linux import acl_get, acl_set, umount, API_VERSION
22 28
elif sys.platform.startswith('freebsd'):  # pragma: freebsd only
23 29
    from .platform_freebsd import acl_get, acl_set, API_VERSION
24 30
elif sys.platform == 'darwin':  # pragma: darwin only
25 31
    from .platform_darwin import acl_get, acl_set, API_VERSION
26 32
else:  # pragma: unknown platform only
27 -
    API_VERSION = 2
33 +
    API_VERSION = '1.0_01'
28 34
29 35
    def acl_get(path, item, st, numeric_owner=False):
30 36
        pass
@@ -31 +37 @@
Loading

@@ -1,37 +1,41 @@
Loading
1 -
from binascii import hexlify, unhexlify
1 +
from binascii import unhexlify
2 2
from datetime import datetime
3 3
from hashlib import sha256
4 4
from operator import attrgetter
5 5
import argparse
6 +
import faulthandler
6 7
import functools
7 8
import inspect
8 -
import io
9 9
import os
10 10
import re
11 11
import shlex
12 12
import signal
13 13
import stat
14 14
import sys
15 15
import textwrap
16 +
import time
16 17
import traceback
17 18
import collections
18 19
19 20
from . import __version__
20 21
from .helpers import Error, location_validator, archivename_validator, format_line, format_time, format_file_size, \
21 -
    parse_pattern, PathPrefixPattern, to_localtime, timestamp, safe_timestamp, \
22 +
    parse_pattern, PathPrefixPattern, to_localtime, timestamp, safe_timestamp, bin_to_hex, \
22 23
    get_cache_dir, prune_within, prune_split, \
23 24
    Manifest, NoManifestError, remove_surrogates, update_excludes, format_archive, check_extension_modules, Statistics, \
24 25
    dir_is_tagged, bigint_to_int, ChunkerParams, CompressionSpec, PrefixSpec, is_slow_msgpack, yes, sysinfo, \
25 26
    EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, log_multi, PatternMatcher, ErrorIgnoringTextIOWrapper
27 +
from .helpers import signal_handler, raising_signal_handler, SigHup, SigTerm
26 28
from .logger import create_logger, setup_logging
27 29
logger = create_logger()
28 30
from .compress import Compressor
29 31
from .upgrader import AtticRepositoryUpgrader, BorgRepositoryUpgrader
30 32
from .repository import Repository
31 33
from .cache import Cache
32 -
from .key import key_creator, RepoKey, PassphraseKey
34 +
from .key import key_creator, tam_required_file, tam_required, RepoKey, PassphraseKey
35 +
from .keymanager import KeyManager
33 36
from .archive import backup_io, BackupOSError, Archive, ArchiveChecker, CHUNKER_PARAMS, is_special
34 37
from .remote import RepositoryServer, RemoteRepository, cache_if_remote
38 +
from .platform import umount
35 39
36 40
has_lchflags = hasattr(os, 'lchflags')
37 41
@@ -45,10 +49,12 @@
Loading
45 49
    """If bool is passed, return it. If str is passed, retrieve named attribute from args."""
46 50
    if isinstance(str_or_bool, str):
47 51
        return getattr(args, str_or_bool)
52 +
    if isinstance(str_or_bool, (list, tuple)):
53 +
        return any(getattr(args, item) for item in str_or_bool)
48 54
    return str_or_bool
49 55
50 56
51 -
def with_repository(fake=False, create=False, lock=True, exclusive=False, manifest=True, cache=False):
57 +
def with_repository(fake=False, invert_fake=False, create=False, lock=True, exclusive=False, manifest=True, cache=False):
52 58
    """
53 59
    Method decorator for subcommand-handling methods: do_XYZ(self, args, repository, …)
54 60
@@ -65,7 +71,7 @@
Loading
65 71
        def wrapper(self, args, **kwargs):
66 72
            location = args.location  # note: 'location' must be always present in args
67 73
            append_only = getattr(args, 'append_only', False)
68 -
            if argument(args, fake):
74 +
            if argument(args, fake) ^ invert_fake:
69 75
                return method(self, args, repository=None, **kwargs)
70 76
            elif location.proto == 'ssh':
71 77
                repository = RemoteRepository(location, create=create, exclusive=argument(args, exclusive),
@@ -124,14 +130,28 @@
Loading
124 130
    @with_repository(create=True, exclusive=True, manifest=False)
125 131
    def do_init(self, args, repository):
126 132
        """Initialize an empty repository"""
127 -
        logger.info('Initializing repository at "%s"' % args.location.canonical_path())
133 +
        path = args.location.canonical_path()
134 +
        logger.info('Initializing repository at "%s"' % path)
128 135
        key = key_creator(repository, args)
129 136
        manifest = Manifest(key, repository)
130 137
        manifest.key = key
131 138
        manifest.write()
132 139
        repository.commit()
133 140
        with Cache(repository, key, manifest, warn_if_unencrypted=False):
134 141
            pass
142 +
        if key.tam_required:
143 +
            tam_file = tam_required_file(repository)
144 +
            open(tam_file, 'w').close()
145 +
            logger.warning(
146 +
                '\n'
147 +
                'By default repositories initialized with this version will produce security\n'
148 +
                'errors if written to with an older version (up to and including Borg 1.0.8).\n'
149 +
                '\n'
150 +
                'If you want to use these older versions, you can disable the check by runnning:\n'
151 +
                'borg upgrade --disable-tam \'%s\'\n'
152 +
                '\n'
153 +
                'See https://borgbackup.readthedocs.io/en/stable/changes.html#pre-1-0-9-manifest-spoofing-vulnerability '
154 +
                'for details about the security implications.', path)
135 155
        return self.exit_code
136 156
137 157
    @with_repository(exclusive=True, manifest=False)
@@ -141,7 +161,8 @@
Loading
141 161
            msg = ("'check --repair' is an experimental feature that might result in data loss." +
142 162
                   "\n" +
143 163
                   "Type 'YES' if you understand this and want to continue: ")
144 -
            if not yes(msg, false_msg="Aborting.", truish=('YES', ),
164 +
            if not yes(msg, false_msg="Aborting.", invalid_msg="Invalid answer, aborting.",
165 +
                       truish=('YES', ), retry=False,
145 166
                       env_var_override='BORG_CHECK_I_KNOW_WHAT_I_AM_DOING'):
146 167
                return EXIT_ERROR
147 168
        if not args.archives_only:
@@ -156,7 +177,50 @@
Loading
156 177
    @with_repository()
157 178
    def do_change_passphrase(self, args, repository, manifest, key):
158 179
        """Change repository key file passphrase"""
180 +
        if not hasattr(key, 'change_passphrase'):
181 +
            print('This repository is not encrypted, cannot change the passphrase.')
182 +
            return EXIT_ERROR
159 183
        key.change_passphrase()
184 +
        logger.info('Key updated')
185 +
        if hasattr(key, 'find_key'):
186 +
            # print key location to make backing it up easier
187 +
            logger.info('Key location: %s', key.find_key())
188 +
        return EXIT_SUCCESS
189 +
190 +
    @with_repository(lock=False, exclusive=False, manifest=False, cache=False)
191 +
    def do_key_export(self, args, repository):
192 +
        """Export the repository key for backup"""
193 +
        manager = KeyManager(repository)
194 +
        manager.load_keyblob()
195 +
        if args.paper:
196 +
            manager.export_paperkey(args.path)
197 +
        else:
198 +
            if not args.path:
199 +
                self.print_error("output file to export key to expected")
200 +
                return EXIT_ERROR
201 +
            if args.qr:
202 +
                manager.export_qr(args.path)
203 +
            else:
204 +
                manager.export(args.path)
205 +
        return EXIT_SUCCESS
206 +
207 +
    @with_repository(lock=False, exclusive=False, manifest=False, cache=False)
208 +
    def do_key_import(self, args, repository):
209 +
        """Import the repository key from backup"""
210 +
        manager = KeyManager(repository)
211 +
        if args.paper:
212 +
            if args.path:
213 +
                self.print_error("with --paper import from file is not supported")
214 +
                return EXIT_ERROR
215 +
            manager.import_paperkey(args)
216 +
        else:
217 +
            if not args.path:
218 +
                self.print_error("input file to import key from expected")
219 +
                return EXIT_ERROR
220 +
            if not os.path.exists(args.path):
221 +
                self.print_error("input file does not exist: " + args.path)
222 +
                return EXIT_ERROR
223 +
            manager.import_keyfile(args)
160 224
        return EXIT_SUCCESS
161 225
162 226
    @with_repository(manifest=False)
@@ -172,6 +236,7 @@
Loading
172 236
        key_new.id_key = key_old.id_key
173 237
        key_new.chunk_seed = key_old.chunk_seed
174 238
        key_new.change_passphrase()  # option to change key protection passphrase, save
239 +
        logger.info('Key updated')
175 240
        return EXIT_SUCCESS
176 241
177 242
    @with_repository(fake='dry_run', exclusive=True)
@@ -226,7 +291,6 @@
Loading
226 291
                if args.progress:
227 292
                    archive.stats.show_progress(final=True)
228 293
                if args.stats:
229 -
                    archive.end = datetime.utcnow()
230 294
                    log_multi(DASHES,
231 295
                              str(archive),
232 296
                              DASHES,
@@ -239,13 +303,15 @@
Loading
239 303
        self.ignore_inode = args.ignore_inode
240 304
        dry_run = args.dry_run
241 305
        t0 = datetime.utcnow()
306 +
        t0_monotonic = time.monotonic()
242 307
        if not dry_run:
243 308
            key.compressor = Compressor(**args.compression)
244 309
            with Cache(repository, key, manifest, do_files=args.cache_files, lock_wait=self.lock_wait) as cache:
245 310
                archive = Archive(repository, key, manifest, args.location.archive, cache=cache,
246 311
                                  create=True, checkpoint_interval=args.checkpoint_interval,
247 -
                                  numeric_owner=args.numeric_owner, progress=args.progress,
248 -
                                  chunker_params=args.chunker_params, start=t0)
312 +
                                  numeric_owner=args.numeric_owner, noatime=args.noatime, noctime=args.noctime,
313 +
                                  progress=args.progress,
314 +
                                  chunker_params=args.chunker_params, start=t0, start_monotonic=t0_monotonic)
249 315
                create_inner(archive, cache)
250 316
        else:
251 317
            create_inner(None, None)
@@ -306,8 +372,13 @@
Loading
306 372
                if not read_special:
307 373
                    status = archive.process_symlink(path, st)
308 374
                else:
309 -
                    st_target = os.stat(path)
310 -
                    if is_special(st_target.st_mode):
375 +
                    try:
376 +
                        st_target = os.stat(path)
377 +
                    except OSError:
378 +
                        special = False
379 +
                    else:
380 +
                        special = is_special(st_target.st_mode)
381 +
                    if special:
311 382
                        status = archive.process_file(path, st_target, cache)
312 383
                    else:
313 384
                        status = archive.process_symlink(path, st)
@@ -461,8 +532,8 @@
Loading
461 532
                        msg.append(format_archive(archive_info))
462 533
                msg.append("Type 'YES' if you understand this and want to continue: ")
463 534
                msg = '\n'.join(msg)
464 -
                if not yes(msg, false_msg="Aborting.", truish=('YES', ),
465 -
                           env_var_override='BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'):
535 +
                if not yes(msg, false_msg="Aborting.", invalid_msg='Invalid answer, aborting.', truish=('YES', ),
536 +
                           retry=False, env_var_override='BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'):
466 537
                    self.exit_code = EXIT_ERROR
467 538
                    return self.exit_code
468 539
                repository.destroy()
@@ -473,7 +544,7 @@
Loading
473 544
474 545
    @with_repository()
475 546
    def do_mount(self, args, repository, manifest, key):
476 -
        """Mount archive or an entire repository as a FUSE fileystem"""
547 +
        """Mount archive or an entire repository as a FUSE filesystem"""
477 548
        try:
478 549
            from .fuse import FuseOperations
479 550
        except ImportError as e:
@@ -498,6 +569,10 @@
Loading
498 569
                self.exit_code = EXIT_ERROR
499 570
        return self.exit_code
500 571
572 +
    def do_umount(self, args):
573 +
        """un-mount the FUSE filesystem"""
574 +
        return umount(args.mountpoint)
575 +
501 576
    @with_repository()
502 577
    def do_list(self, args, repository, manifest, key):
503 578
        """List archive or repository contents"""
@@ -590,7 +665,7 @@
Loading
590 665
        """Show archive details such as disk space used"""
591 666
        stats = archive.calc_stats(cache)
592 667
        print('Name:', archive.name)
593 -
        print('Fingerprint: %s' % hexlify(archive.id).decode('ascii'))
668 +
        print('Fingerprint: %s' % bin_to_hex(archive.id))
594 669
        print('Hostname:', archive.metadata[b'hostname'])
595 670
        print('Username:', archive.metadata[b'username'])
596 671
        print('Time (start): %s' % format_time(to_localtime(archive.ts)))
@@ -658,21 +733,61 @@
Loading
658 733
                          DASHES)
659 734
        return self.exit_code
660 735
661 -
    def do_upgrade(self, args):
736 +
    @with_repository(fake=('tam', 'disable_tam'), invert_fake=True, manifest=False, exclusive=True)
737 +
    def do_upgrade(self, args, repository, manifest=None, key=None):
662 738
        """upgrade a repository from a previous version"""
663 -
        # mainly for upgrades from Attic repositories,
664 -
        # but also supports borg 0.xx -> 1.0 upgrade.
739 +
        if args.tam:
740 +
            manifest, key = Manifest.load(repository, force_tam_not_required=args.force)
665 741
666 -
        repo = AtticRepositoryUpgrader(args.location.path, create=False)
667 -
        try:
668 -
            repo.upgrade(args.dry_run, inplace=args.inplace, progress=args.progress)
669 -
        except NotImplementedError as e:
670 -
            print("warning: %s" % e)
671 -
        repo = BorgRepositoryUpgrader(args.location.path, create=False)
672 -
        try:
673 -
            repo.upgrade(args.dry_run, inplace=args.inplace, progress=args.progress)
674 -
        except NotImplementedError as e:
675 -
            print("warning: %s" % e)
742 +
            if not hasattr(key, 'change_passphrase'):
743 +
                print('This repository is not encrypted, cannot enable TAM.')
744 +
                return EXIT_ERROR
745 +
746 +
            if not manifest.tam_verified or not manifest.config.get(b'tam_required', False):
747 +
                # The standard archive listing doesn't include the archive ID like in borg 1.1.x
748 +
                print('Manifest contents:')
749 +
                for archive_info in manifest.list_archive_infos(sort_by='ts'):
750 +
                    print(format_archive(archive_info), '[%s]' % bin_to_hex(archive_info.id))
751 +
                manifest.config[b'tam_required'] = True
752 +
                manifest.write()
753 +
                repository.commit()
754 +
            if not key.tam_required:
755 +
                key.tam_required = True
756 +
                key.change_passphrase(key._passphrase)
757 +
                print('Key updated')
758 +
                if hasattr(key, 'find_key'):
759 +
                    print('Key location:', key.find_key())
760 +
            if not tam_required(repository):
761 +
                tam_file = tam_required_file(repository)
762 +
                open(tam_file, 'w').close()
763 +
                print('Updated security database')
764 +
        elif args.disable_tam:
765 +
            manifest, key = Manifest.load(repository, force_tam_not_required=True)
766 +
            if tam_required(repository):
767 +
                os.unlink(tam_required_file(repository))
768 +
            if key.tam_required:
769 +
                key.tam_required = False
770 +
                key.change_passphrase(key._passphrase)
771 +
                print('Key updated')
772 +
                if hasattr(key, 'find_key'):
773 +
                    print('Key location:', key.find_key())
774 +
            manifest.config[b'tam_required'] = False
775 +
            manifest.write()
776 +
            repository.commit()
777 +
        else:
778 +
            # mainly for upgrades from Attic repositories,
779 +
            # but also supports borg 0.xx -> 1.0 upgrade.
780 +
781 +
            repo = AtticRepositoryUpgrader(args.location.path, create=False)
782 +
            try:
783 +
                repo.upgrade(args.dry_run, inplace=args.inplace, progress=args.progress)
784 +
            except NotImplementedError as e:
785 +
                print("warning: %s" % e)
786 +
            repo = BorgRepositoryUpgrader(args.location.path, create=False)
787 +
            try:
788 +
                repo.upgrade(args.dry_run, inplace=args.inplace, progress=args.progress)
789 +
            except NotImplementedError as e:
790 +
                print("warning: %s" % e)
676 791
        return self.exit_code
677 792
678 793
    def do_debug_info(self, args):
@@ -686,7 +801,7 @@
Loading
686 801
        archive = Archive(repository, key, manifest, args.location.archive)
687 802
        for i, item_id in enumerate(archive.metadata[b'items']):
688 803
            data = key.decrypt(item_id, repository.get(item_id))
689 -
            filename = '%06d_%s.items' % (i, hexlify(item_id).decode('ascii'))
804 +
            filename = '%06d_%s.items' % (i, bin_to_hex(item_id))
690 805
            print('Dumping', filename)
691 806
            with open(filename, 'wb') as fd:
692 807
                fd.write(data)
@@ -707,7 +822,7 @@
Loading
707 822
                cdata = repository.get(id)
708 823
                give_id = id if id != Manifest.MANIFEST_ID else None
709 824
                data = key.decrypt(give_id, cdata)
710 -
                filename = '%06d_%s.obj' % (i, hexlify(id).decode('ascii'))
825 +
                filename = '%06d_%s.obj' % (i, bin_to_hex(id))
711 826
                print('Dumping', filename)
712 827
                with open(filename, 'wb') as fd:
713 828
                    fd.write(data)
@@ -726,7 +841,7 @@
Loading
726 841
        else:
727 842
            try:
728 843
                data = repository.get(id)
729 -
            except repository.ObjectNotFound:
844 +
            except Repository.ObjectNotFound:
730 845
                print("object %s not found." % hex_id)
731 846
            else:
732 847
                with open(args.path, "wb") as f:
@@ -760,13 +875,29 @@
Loading
760 875
                    repository.delete(id)
761 876
                    modified = True
762 877
                    print("object %s deleted." % hex_id)
763 -
                except repository.ObjectNotFound:
878 +
                except Repository.ObjectNotFound:
764 879
                    print("object %s not found." % hex_id)
765 880
        if modified:
766 881
            repository.commit()
767 882
        print('Done.')
768 883
        return EXIT_SUCCESS
769 884
885 +
    @with_repository(manifest=False, exclusive=True, cache=True)
886 +
    def do_debug_refcount_obj(self, args, repository, manifest, key, cache):
887 +
        """display refcounts for the objects with the given IDs"""
888 +
        for hex_id in args.ids:
889 +
            try:
890 +
                id = unhexlify(hex_id)
891 +
            except ValueError:
892 +
                print("object id %s is invalid." % hex_id)
893 +
            else:
894 +
                try:
895 +
                    refcount = cache.chunks[id][0]
896 +
                    print("object %s has %d referrers [info from chunks cache]." % (hex_id, refcount))
897 +
                except KeyError:
898 +
                    print("object %s not found [info from chunks cache]." % hex_id)
899 +
        return EXIT_SUCCESS
900 +
770 901
    @with_repository(lock=False, manifest=False)
771 902
    def do_break_lock(self, args, repository):
772 903
        """Break the repository lock (e.g. in case it was left by a dead borg."""
@@ -892,7 +1023,19 @@
Loading
892 1023
893 1024
        {borgversion}
894 1025
895 -
            The version of borg.
1026 +
            The version of borg, e.g.: 1.0.8rc1
1027 +
1028 +
        {borgmajor}
1029 +
1030 +
            The version of borg, only the major version, e.g.: 1
1031 +
1032 +
        {borgminor}
1033 +
1034 +
            The version of borg, only major and minor version, e.g.: 1.0
1035 +
1036 +
        {borgpatch}
1037 +
1038 +
            The version of borg, only major, minor and patch version, e.g.: 1.0.8
896 1039
897 1040
       Examples::
898 1041
@@ -917,6 +1060,11 @@
Loading
917 1060
            parser.error('No help available on %s' % (args.topic,))
918 1061
        return self.exit_code
919 1062
1063 +
    def do_subcommand_help(self, parser, args):
1064 +
        """display infos about subcommand"""
1065 +
        parser.print_help()
1066 +
        return EXIT_SUCCESS
1067 +
920 1068
    def preprocess_args(self, args):
921 1069
        deprecations = [
922 1070
            # ('--old', '--new', 'Warning: "--old" has been deprecated. Use "--new" instead.'),
@@ -970,7 +1118,9 @@
Loading
970 1118
                                          help='start repository server process')
971 1119
        subparser.set_defaults(func=self.do_serve)
972 1120
        subparser.add_argument('--restrict-to-path', dest='restrict_to_paths', action='append',
973 -
                               metavar='PATH', help='restrict repository access to PATH')
1121 +
                               metavar='PATH', help='restrict repository access to PATH. '
1122 +
                                                    'Can be specified multiple times to allow the client access to several directories. '
1123 +
                                                    'Access to all sub-directories is granted implicitly; PATH doesn\'t need to directly point to a repository.')
974 1124
        subparser.add_argument('--append-only', dest='append_only', action='store_true',
975 1125
                               help='only allow appending to repository segment files')
976 1126
        init_epilog = textwrap.dedent("""
@@ -1071,6 +1221,73 @@
Loading
1071 1221
        subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='',
1072 1222
                               type=location_validator(archive=False))
1073 1223
1224 +
        subparser = subparsers.add_parser('key', parents=[common_parser],
1225 +
                                          description="Manage a keyfile or repokey of a repository",
1226 +
                                          epilog="",
1227 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1228 +
                                          help='manage repository key')
1229 +
1230 +
        key_parsers = subparser.add_subparsers(title='required arguments', metavar='<command>')
1231 +
        subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser))
1232 +
1233 +
        key_export_epilog = textwrap.dedent("""
1234 +
        If repository encryption is used, the repository is inaccessible
1235 +
        without the key. This command allows to backup this essential key.
1236 +
1237 +
        There are two backup formats. The normal backup format is suitable for
1238 +
        digital storage as a file. The ``--paper`` backup format is optimized
1239 +
        for printing and typing in while importing, with per line checks to
1240 +
        reduce problems with manual input.
1241 +
1242 +
        For repositories using keyfile encryption the key is saved locally
1243 +
        on the system that is capable of doing backups. To guard against loss
1244 +
        of this key, the key needs to be backed up independently of the main
1245 +
        data backup.
1246 +
1247 +
        For repositories using the repokey encryption the key is saved in the
1248 +
        repository in the config file. A backup is thus not strictly needed,
1249 +
        but guards against the repository becoming inaccessible if the file
1250 +
        is damaged for some reason.
1251 +
        """)
1252 +
        subparser = key_parsers.add_parser('export', parents=[common_parser],
1253 +
                                          description=self.do_key_export.__doc__,
1254 +
                                          epilog=key_export_epilog,
1255 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1256 +
                                          help='export repository key for backup')
1257 +
        subparser.set_defaults(func=self.do_key_export)
1258 +
        subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='',
1259 +
                               type=location_validator(archive=False))
1260 +
        subparser.add_argument('path', metavar='PATH', nargs='?', type=str,
1261 +
                               help='where to store the backup')
1262 +
        subparser.add_argument('--paper', dest='paper', action='store_true',
1263 +
                               default=False,
1264 +
                               help='Create an export suitable for printing and later type-in')
1265 +
        subparser.add_argument('--qr-html', dest='qr', action='store_true',
1266 +
                               default=False,
1267 +
                               help='Create an html file suitable for printing and later type-in or qr scan')
1268 +
1269 +
        key_import_epilog = textwrap.dedent("""
1270 +
        This command allows to restore a key previously backed up with the
1271 +
        export command.
1272 +
1273 +
        If the ``--paper`` option is given, the import will be an interactive
1274 +
        process in which each line is checked for plausibility before
1275 +
        proceeding to the next line. For this format PATH must not be given.
1276 +
        """)
1277 +
        subparser = key_parsers.add_parser('import', parents=[common_parser],
1278 +
                                          description=self.do_key_import.__doc__,
1279 +
                                          epilog=key_import_epilog,
1280 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1281 +
                                          help='import repository key from backup')
1282 +
        subparser.set_defaults(func=self.do_key_import)
1283 +
        subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='',
1284 +
                               type=location_validator(archive=False))
1285 +
        subparser.add_argument('path', metavar='PATH', nargs='?', type=str,
1286 +
                               help='path to the backup')
1287 +
        subparser.add_argument('--paper', dest='paper', action='store_true',
1288 +
                               default=False,
1289 +
                               help='interactively import from a backup done with --paper')
1290 +
1074 1291
        migrate_to_repokey_epilog = textwrap.dedent("""
1075 1292
        This command migrates a repository from passphrase mode (not supported any
1076 1293
        more) to repokey mode.
@@ -1100,15 +1317,19 @@
Loading
1100 1317
1101 1318
        create_epilog = textwrap.dedent("""
1102 1319
        This command creates a backup archive containing all files found while recursively
1103 -
        traversing all paths specified. The archive will consume almost no disk space for
1104 -
        files or parts of files that have already been stored in other archives.
1320 +
        traversing all paths specified. When giving '-' as path, borg will read data
1321 +
        from standard input and create a file 'stdin' in the created archive from that
1322 +
        data.
1323 +
1324 +
        The archive will consume almost no disk space for files or parts of files that
1325 +
        have already been stored in other archives.
1105 1326
1106 1327
        The archive name needs to be unique. It must not end in '.checkpoint' or
1107 1328
        '.checkpoint.N' (with N being a number), because these names are used for
1108 1329
        checkpoints and treated in special ways.
1109 1330
1110 -
        In the archive name, you may use the following format tags:
1111 -
        {now}, {utcnow}, {fqdn}, {hostname}, {user}, {pid}, {borgversion}
1331 +
        In the archive name, you may use the following placeholders:
1332 +
        {now}, {utcnow}, {fqdn}, {hostname}, {user} and some others.
1112 1333
1113 1334
        To speed up pulling backups over sshfs and similar network file systems which do
1114 1335
        not provide correct inode information the --ignore-inode flag can be used. This
@@ -1158,10 +1379,16 @@
Loading
1158 1379
                               help='write checkpoint every SECONDS seconds (Default: 300)')
1159 1380
        subparser.add_argument('-x', '--one-file-system', dest='one_file_system',
1160 1381
                               action='store_true', default=False,
1161 -
                               help='stay in same file system, do not cross mount points')
1382 +
                               help='stay in same file system')
1162 1383
        subparser.add_argument('--numeric-owner', dest='numeric_owner',
1163 1384
                               action='store_true', default=False,
1164 1385
                               help='only store numeric user and group identifiers')
1386 +
        subparser.add_argument('--noatime', dest='noatime',
1387 +
                               action='store_true', default=False,
1388 +
                               help='do not store atime into archive')
1389 +
        subparser.add_argument('--noctime', dest='noctime',
1390 +
                               action='store_true', default=False,
1391 +
                               help='do not store ctime into archive')
1165 1392
        subparser.add_argument('--timestamp', dest='timestamp',
1166 1393
                               type=timestamp, default=None,
1167 1394
                               metavar='yyyy-mm-ddThh:mm:ss',
@@ -1325,6 +1552,13 @@
Loading
1325 1552
        - allow_damaged_files: by default damaged files (where missing chunks were
1326 1553
          replaced with runs of zeros by borg check --repair) are not readable and
1327 1554
          return EIO (I/O error). Set this option to read such files.
1555 +
1556 +
        When the daemonized process receives a signal or crashes, it does not unmount.
1557 +
        Unmounting in these cases could cause an active rsync or similar process
1558 +
        to unintentionally delete data.
1559 +
1560 +
        When running in the foreground ^C/SIGINT unmounts cleanly, but other
1561 +
        signals or crashes do not.
1328 1562
        """)
1329 1563
        subparser = subparsers.add_parser('mount', parents=[common_parser],
1330 1564
                                          description=self.do_mount.__doc__,
@@ -1342,8 +1576,32 @@
Loading
1342 1576
        subparser.add_argument('-o', dest='options', type=str,
1343 1577
                               help='Extra mount options')
1344 1578
1579 +
        umount_epilog = textwrap.dedent("""
1580 +
        This command un-mounts a FUSE filesystem that was mounted with ``borg mount``.
1581 +
1582 +
        This is a convenience wrapper that just calls the platform-specific shell
1583 +
        command - usually this is either umount or fusermount -u.
1584 +
        """)
1585 +
        subparser = subparsers.add_parser('umount', parents=[common_parser],
1586 +
                                          description=self.do_umount.__doc__,
1587 +
                                          epilog=umount_epilog,
1588 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1589 +
                                          help='umount repository')
1590 +
        subparser.set_defaults(func=self.do_umount)
1591 +
        subparser.add_argument('mountpoint', metavar='MOUNTPOINT', type=str,
1592 +
                               help='mountpoint of the filesystem to umount')
1593 +
1345 1594
        info_epilog = textwrap.dedent("""
1346 1595
        This command displays some detailed information about the specified archive.
1596 +
1597 +
        Please note that the deduplicated sizes of the individual archives do not add
1598 +
        up to the deduplicated size of the repository ("all archives"), because the two
1599 +
        are meaning different things:
1600 +
1601 +
        This archive / deduplicated size = amount of data stored ONLY for this archive
1602 +
                                         = unique chunks of this archive.
1603 +
        All archives / deduplicated size = amount of data stored in the repo
1604 +
                                         = all chunks in the repository.
1347 1605
        """)
1348 1606
        subparser = subparsers.add_parser('info', parents=[common_parser],
1349 1607
                                          description=self.do_info.__doc__,
@@ -1393,6 +1651,8 @@
Loading
1393 1651
        considered for deletion and only those archives count towards the totals
1394 1652
        specified by the rules.
1395 1653
        Otherwise, *all* archives in the repository are candidates for deletion!
1654 +
        There is no automatic distinction between archives representing different
1655 +
        contents. These need to be distinguished by specifying matching prefixes.
1396 1656
        """)
1397 1657
        subparser = subparsers.add_parser('prune', parents=[common_parser],
1398 1658
                                          description=self.do_prune.__doc__,
@@ -1435,6 +1695,32 @@
Loading
1435 1695
1436 1696
        upgrade_epilog = textwrap.dedent("""
1437 1697
        Upgrade an existing Borg repository.
1698 +
1699 +
        Borg 1.x.y upgrades
1700 +
        +++++++++++++++++++
1701 +
1702 +
        Use ``borg upgrade --tam REPO`` to require manifest authentication
1703 +
        introduced with Borg 1.0.9 to address security issues. This means
1704 +
        that modifying the repository after doing this with a version prior
1705 +
        to 1.0.9 will raise a validation error, so only perform this upgrade
1706 +
        after updating all clients using the repository to 1.0.9 or newer.
1707 +
1708 +
        This upgrade should be done on each client for safety reasons.
1709 +
1710 +
        If a repository is accidentally modified with a pre-1.0.9 client after
1711 +
        this upgrade, use ``borg upgrade --tam --force REPO`` to remedy it.
1712 +
1713 +
        If you routinely do this you might not want to enable this upgrade
1714 +
        (which will leave you exposed to the security issue). You can
1715 +
        reverse the upgrade by issuing ``borg upgrade --disable-tam REPO``.
1716 +
1717 +
        See
1718 +
        https://borgbackup.readthedocs.io/en/stable/changes.html#pre-1-0-9-manifest-spoofing-vulnerability
1719 +
        for details.
1720 +
1721 +
        Attic and Borg 0.xx to Borg 1.x
1722 +
        +++++++++++++++++++++++++++++++
1723 +
1438 1724
        This currently supports converting an Attic repository to Borg and also
1439 1725
        helps with converting Borg 0.xx to 1.0.
1440 1726
@@ -1487,6 +1773,12 @@
Loading
1487 1773
                               default=False, action='store_true',
1488 1774
                               help="""rewrite repository in place, with no chance of going back to older
1489 1775
                               versions of the repository.""")
1776 +
        subparser.add_argument('--force', dest='force', action='store_true',
1777 +
                               help="""Force upgrade""")
1778 +
        subparser.add_argument('--tam', dest='tam', action='store_true',
1779 +
                               help="""Enable manifest authentication (in key and cache) (Borg 1.0.9 and later)""")
1780 +
        subparser.add_argument('--disable-tam', dest='disable_tam', action='store_true',
1781 +
                               help="""Disable manifest authentication (in key and cache)""")
1490 1782
        subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='',
1491 1783
                               type=location_validator(archive=False),
1492 1784
                               help='path to the repository to be upgraded')
@@ -1501,6 +1793,23 @@
Loading
1501 1793
        subparser.add_argument('topic', metavar='TOPIC', type=str, nargs='?',
1502 1794
                               help='additional help on TOPIC')
1503 1795
1796 +
        debug_epilog = textwrap.dedent("""
1797 +
        These commands are not intended for normal use and potentially very
1798 +
        dangerous if used incorrectly.
1799 +
1800 +
        They exist to improve debugging capabilities without direct system access, e.g.
1801 +
        in case you ever run into some severe malfunction. Use them only if you know
1802 +
        what you are doing or if a trusted developer tells you what to do.""")
1803 +
1804 +
        subparser = subparsers.add_parser('debug', parents=[common_parser],
1805 +
                                          description='debugging command (not intended for normal use)',
1806 +
                                          epilog=debug_epilog,
1807 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1808 +
                                          help='debugging command (not intended for normal use)')
1809 +
1810 +
        debug_parsers = subparser.add_subparsers(title='required arguments', metavar='<command>')
1811 +
        subparser.set_defaults(fallback_func=functools.partial(self.do_subcommand_help, subparser))
1812 +
1504 1813
        debug_info_epilog = textwrap.dedent("""
1505 1814
        This command displays some system information that might be useful for bug
1506 1815
        reports and debugging problems. If a traceback happens, this information is
@@ -1513,6 +1822,13 @@
Loading
1513 1822
                                          help='show system infos for debugging / bug reports (debug)')
1514 1823
        subparser.set_defaults(func=self.do_debug_info)
1515 1824
1825 +
        subparser = debug_parsers.add_parser('info', parents=[common_parser],
1826 +
                                          description=self.do_debug_info.__doc__,
1827 +
                                          epilog=debug_info_epilog,
1828 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1829 +
                                          help='show system infos for debugging / bug reports (debug)')
1830 +
        subparser.set_defaults(func=self.do_debug_info)
1831 +
1516 1832
        debug_dump_archive_items_epilog = textwrap.dedent("""
1517 1833
        This command dumps raw (but decrypted and decompressed) archive items (only metadata) to files.
1518 1834
        """)
@@ -1526,6 +1842,16 @@
Loading
1526 1842
                               type=location_validator(archive=True),
1527 1843
                               help='archive to dump')
1528 1844
1845 +
        subparser = debug_parsers.add_parser('dump-archive-items', parents=[common_parser],
1846 +
                                          description=self.do_debug_dump_archive_items.__doc__,
1847 +
                                          epilog=debug_dump_archive_items_epilog,
1848 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1849 +
                                          help='dump archive items (metadata) (debug)')
1850 +
        subparser.set_defaults(func=self.do_debug_dump_archive_items)
1851 +
        subparser.add_argument('location', metavar='ARCHIVE',
1852 +
                               type=location_validator(archive=True),
1853 +
                               help='archive to dump')
1854 +
1529 1855
        debug_dump_repo_objs_epilog = textwrap.dedent("""
1530 1856
        This command dumps raw (but decrypted and decompressed) repo objects to files.
1531 1857
        """)
@@ -1539,6 +1865,16 @@
Loading
1539 1865
                               type=location_validator(archive=False),
1540 1866
                               help='repo to dump')
1541 1867
1868 +
        subparser = debug_parsers.add_parser('dump-repo-objs', parents=[common_parser],
1869 +
                                          description=self.do_debug_dump_repo_objs.__doc__,
1870 +
                                          epilog=debug_dump_repo_objs_epilog,
1871 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1872 +
                                          help='dump repo objects (debug)')
1873 +
        subparser.set_defaults(func=self.do_debug_dump_repo_objs)
1874 +
        subparser.add_argument('location', metavar='REPOSITORY',
1875 +
                               type=location_validator(archive=False),
1876 +
                               help='repo to dump')
1877 +
1542 1878
        debug_get_obj_epilog = textwrap.dedent("""
1543 1879
        This command gets an object from the repository.
1544 1880
        """)
@@ -1556,6 +1892,20 @@
Loading
1556 1892
        subparser.add_argument('path', metavar='PATH', type=str,
1557 1893
                               help='file to write object data into')
1558 1894
1895 +
        subparser = debug_parsers.add_parser('get-obj', parents=[common_parser],
1896 +
                                          description=self.do_debug_get_obj.__doc__,
1897 +
                                          epilog=debug_get_obj_epilog,
1898 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1899 +
                                          help='get object from repository (debug)')
1900 +
        subparser.set_defaults(func=self.do_debug_get_obj)
1901 +
        subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='',
1902 +
                               type=location_validator(archive=False),
1903 +
                               help='repository to use')
1904 +
        subparser.add_argument('id', metavar='ID', type=str,
1905 +
                               help='hex object ID to get from the repo')
1906 +
        subparser.add_argument('path', metavar='PATH', type=str,
1907 +
                               help='file to write object data into')
1908 +
1559 1909
        debug_put_obj_epilog = textwrap.dedent("""
1560 1910
        This command puts objects into the repository.
1561 1911
        """)
@@ -1571,6 +1921,18 @@
Loading
1571 1921
        subparser.add_argument('paths', metavar='PATH', nargs='+', type=str,
1572 1922
                               help='file(s) to read and create object(s) from')
1573 1923
1924 +
        subparser = debug_parsers.add_parser('put-obj', parents=[common_parser],
1925 +
                                          description=self.do_debug_put_obj.__doc__,
1926 +
                                          epilog=debug_put_obj_epilog,
1927 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1928 +
                                          help='put object to repository (debug)')
1929 +
        subparser.set_defaults(func=self.do_debug_put_obj)
1930 +
        subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='',
1931 +
                               type=location_validator(archive=False),
1932 +
                               help='repository to use')
1933 +
        subparser.add_argument('paths', metavar='PATH', nargs='+', type=str,
1934 +
                               help='file(s) to read and create object(s) from')
1935 +
1574 1936
        debug_delete_obj_epilog = textwrap.dedent("""
1575 1937
        This command deletes objects from the repository.
1576 1938
        """)
@@ -1585,6 +1947,46 @@
Loading
1585 1947
                               help='repository to use')
1586 1948
        subparser.add_argument('ids', metavar='IDs', nargs='+', type=str,
1587 1949
                               help='hex object ID(s) to delete from the repo')
1950 +
1951 +
        subparser = debug_parsers.add_parser('delete-obj', parents=[common_parser],
1952 +
                                          description=self.do_debug_delete_obj.__doc__,
1953 +
                                          epilog=debug_delete_obj_epilog,
1954 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1955 +
                                          help='delete object from repository (debug)')
1956 +
        subparser.set_defaults(func=self.do_debug_delete_obj)
1957 +
        subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='',
1958 +
                               type=location_validator(archive=False),
1959 +
                               help='repository to use')
1960 +
        subparser.add_argument('ids', metavar='IDs', nargs='+', type=str,
1961 +
                               help='hex object ID(s) to delete from the repo')
1962 +
1963 +
        debug_refcount_obj_epilog = textwrap.dedent("""
1964 +
        This command displays the reference count for objects from the repository.
1965 +
        """)
1966 +
        subparser = subparsers.add_parser('debug-refcount-obj', parents=[common_parser],
1967 +
                                          description=self.do_debug_refcount_obj.__doc__,
1968 +
                                          epilog=debug_refcount_obj_epilog,
1969 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1970 +
                                          help='show refcount for object from repository (debug)')
1971 +
        subparser.set_defaults(func=self.do_debug_refcount_obj)
1972 +
        subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='',
1973 +
                               type=location_validator(archive=False),
1974 +
                               help='repository to use')
1975 +
        subparser.add_argument('ids', metavar='IDs', nargs='+', type=str,
1976 +
                               help='hex object ID(s) to show refcounts for')
1977 +
1978 +
        subparser = debug_parsers.add_parser('refcount-obj', parents=[common_parser],
1979 +
                                          description=self.do_debug_refcount_obj.__doc__,
1980 +
                                          epilog=debug_refcount_obj_epilog,
1981 +
                                          formatter_class=argparse.RawDescriptionHelpFormatter,
1982 +
                                          help='show refcount for object from repository (debug)')
1983 +
        subparser.set_defaults(func=self.do_debug_refcount_obj)
1984 +
        subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='',
1985 +
                               type=location_validator(archive=False),
1986 +
                               help='repository to use')
1987 +
        subparser.add_argument('ids', metavar='IDs', nargs='+', type=str,
1988 +
                               help='hex object ID(s) to show refcounts for')
1989 +
1588 1990
        return parser
1589 1991
1590 1992
    def get_args(self, argv, cmd):
@@ -1614,105 +2016,111 @@
Loading
1614 2016
    def run(self, args):
1615 2017
        os.umask(args.umask)  # early, before opening files
1616 2018
        self.lock_wait = args.lock_wait
1617 -
        setup_logging(level=args.log_level, is_serve=args.func == self.do_serve)  # do not use loggers before this!
2019 +
        # This works around http://bugs.python.org/issue9351
2020 +
        func = getattr(args, 'func', None) or getattr(args, 'fallback_func')
2021 +
        setup_logging(level=args.log_level, is_serve=func == self.do_serve)  # do not use loggers before this!
1618 2022
        check_extension_modules()
1619 2023
        if is_slow_msgpack():
1620 2024
            logger.warning("Using a pure-python msgpack! This will result in lower performance.")
1621 -
        return args.func(args)
2025 +
        return func(args)
1622 2026
1623 2027
1624 -
def sig_info_handler(signum, stack):  # pragma: no cover
2028 +
def sig_info_handler(sig_no, stack):  # pragma: no cover
1625 2029
    """search the stack for infos about the currently processed file and print them"""
1626 -
    for frame in inspect.getouterframes(stack):
1627 -
        func, loc = frame[3], frame[0].f_locals
1628 -
        if func in ('process_file', '_process', ):  # create op
1629 -
            path = loc['path']
1630 -
            try:
1631 -
                pos = loc['fd'].tell()
1632 -
                total = loc['st'].st_size
1633 -
            except Exception:
1634 -
                pos, total = 0, 0
1635 -
            logger.info("{0} {1}/{2}".format(path, format_file_size(pos), format_file_size(total)))
1636 -
            break
1637 -
        if func in ('extract_item', ):  # extract op
1638 -
            path = loc['item'][b'path']
1639 -
            try:
1640 -
                pos = loc['fd'].tell()
1641 -
            except Exception:
1642 -
                pos = 0
1643 -
            logger.info("{0} {1}/???".format(path, format_file_size(pos)))
1644 -
            break
1645 -
1646 -
1647 -
class SIGTERMReceived(BaseException):
1648 -
    pass
1649 -
1650 -
1651 -
def sig_term_handler(signum, stack):
1652 -
    raise SIGTERMReceived
2030 +
    with signal_handler(sig_no, signal.SIG_IGN):
2031 +
        for frame in inspect.getouterframes(stack):
2032 +
            func, loc = frame[3], frame[0].f_locals
2033 +
            if func in ('process_file', '_process', ):  # create op
2034 +
                path = loc['path']
2035 +
                try:
2036 +
                    pos = loc['fd'].tell()
2037 +
                    total = loc['st'].st_size
2038 +
                except Exception:
2039 +
                    pos, total = 0, 0
2040 +
                logger.info("{0} {1}/{2}".format(path, format_file_size(pos), format_file_size(total)))
2041 +
                break
2042 +
            if func in ('extract_item', ):  # extract op
2043 +
                path = loc['item'][b'path']
2044 +
                try:
2045 +
                    pos = loc['fd'].tell()
2046 +
                except Exception:
2047 +
                    pos = 0
2048 +
                logger.info("{0} {1}/???".format(path, format_file_size(pos)))
2049 +
                break
1653 2050
1654 2051
1655 -
def setup_signal_handlers():  # pragma: no cover
1656 -
    sigs = []
1657 -
    if hasattr(signal, 'SIGUSR1'):
1658 -
        sigs.append(signal.SIGUSR1)  # kill -USR1 pid
1659 -
    if hasattr(signal, 'SIGINFO'):
1660 -
        sigs.append(signal.SIGINFO)  # kill -INFO pid (or ctrl-t)
1661 -
    for sig in sigs:
1662 -
        signal.signal(sig, sig_info_handler)
1663 -
    signal.signal(signal.SIGTERM, sig_term_handler)
2052 +
def sig_trace_handler(sig_no, stack):  # pragma: no cover
2053 +
    print('\nReceived SIGUSR2 at %s, dumping trace...' % datetime.now().replace(microsecond=0), file=sys.stderr)
2054 +
    faulthandler.dump_traceback()
1664 2055
1665 2056
1666 2057
def main():  # pragma: no cover
1667 2058
    # Make sure stdout and stderr have errors='replace') to avoid unicode
1668 2059
    # issues when print()-ing unicode file names
1669 2060
    sys.stdout = ErrorIgnoringTextIOWrapper(sys.stdout.buffer, sys.stdout.encoding, 'replace', line_buffering=True)
1670 2061
    sys.stderr = ErrorIgnoringTextIOWrapper(sys.stderr.buffer, sys.stderr.encoding, 'replace', line_buffering=True)
1671 -
    setup_signal_handlers()
1672 -
    archiver = Archiver()
1673 -
    msg = None
1674 -
    try:
1675 -
        args = archiver.get_args(sys.argv, os.environ.get('SSH_ORIGINAL_COMMAND'))
1676 -
    except Error as e:
1677 -
        msg = e.get_message()
1678 -
        if e.traceback:
1679 -
            msg += "\n%s\n%s" % (traceback.format_exc(), sysinfo())
1680 -
        # we might not have logging setup yet, so get out quickly
1681 -
        print(msg, file=sys.stderr)
1682 -
        sys.exit(e.exit_code)
1683 -
    try:
1684 -
        exit_code = archiver.run(args)
1685 -
    except Error as e:
1686 -
        msg = e.get_message()
1687 -
        if e.traceback:
1688 -
            msg += "\n%s\n%s" % (traceback.format_exc(), sysinfo())
1689 -
        exit_code = e.exit_code
1690 -
    except RemoteRepository.RPCError as e:
1691 -
        msg = '%s\n%s' % (str(e), sysinfo())
1692 -
        exit_code = EXIT_ERROR
1693 -
    except Exception:
1694 -
        msg = 'Local Exception.\n%s\n%s' % (traceback.format_exc(), sysinfo())
1695 -
        exit_code = EXIT_ERROR
1696 -
    except KeyboardInterrupt:
1697 -
        msg = 'Keyboard interrupt.\n%s\n%s' % (traceback.format_exc(), sysinfo())
1698 -
        exit_code = EXIT_ERROR
1699 -
    except SIGTERMReceived:
1700 -
        msg = 'Received SIGTERM.'
1701 -
        exit_code = EXIT_ERROR
1702 -
    if msg:
1703 -
        logger.error(msg)
1704 -
    if args.show_rc:
1705 -
        exit_msg = 'terminating with %s status, rc %d'
1706 -
        if exit_code == EXIT_SUCCESS:
1707 -
            logger.info(exit_msg % ('success', exit_code))
1708 -
        elif exit_code == EXIT_WARNING:
1709 -
            logger.warning(exit_msg % ('warning', exit_code))
1710 -
        elif exit_code == EXIT_ERROR:
1711 -
            logger.error(exit_msg % ('error', exit_code))
1712 -
        else:
1713 -
            # if you see 666 in output, it usually means exit_code was None
1714 -
            logger.error(exit_msg % ('abnormal', exit_code or 666))
1715 -
    sys.exit(exit_code)
2062 +
    # If we receive SIGINT (ctrl-c), SIGTERM (kill) or SIGHUP (kill -HUP),
2063 +
    # catch them and raise a proper exception that can be handled for an
2064 +
    # orderly exit.
2065 +
    # SIGHUP is important especially for systemd systems, where logind
2066 +
    # sends it when a session exits, in addition to any traditional use.
2067 +
    # Output some info if we receive SIGUSR1 or SIGINFO (ctrl-t).
2068 +
2069 +
    # Register fault handler for SIGSEGV, SIGFPE, SIGABRT, SIGBUS and SIGILL.
2070 +
    faulthandler.enable()
2071 +
    with signal_handler('SIGINT', raising_signal_handler(KeyboardInterrupt)), \
2072 +
         signal_handler('SIGHUP', raising_signal_handler(SigHup)), \
2073 +
         signal_handler('SIGTERM', raising_signal_handler(SigTerm)), \
2074 +
         signal_handler('SIGUSR1', sig_info_handler), \
2075 +
         signal_handler('SIGUSR2', sig_trace_handler), \
2076 +
         signal_handler('SIGINFO', sig_info_handler):
2077 +
        archiver = Archiver()
2078 +
        msg = None
2079 +
        try:
2080 +
            args = archiver.get_args(sys.argv, os.environ.get('SSH_ORIGINAL_COMMAND'))
2081 +
        except Error as e:
2082 +
            msg = e.get_message()
2083 +
            if e.traceback:
2084 +
                msg += "\n%s\n%s" % (traceback.format_exc(), sysinfo())
2085 +
            # we might not have logging setup yet, so get out quickly
2086 +
            print(msg, file=sys.stderr)
2087 +
            sys.exit(e.exit_code)
2088 +
        try:
2089 +
            exit_code = archiver.run(args)
2090 +
        except Error as e:
2091 +
            msg = e.get_message()
2092 +
            if e.traceback:
2093 +
                msg += "\n%s\n%s" % (traceback.format_exc(), sysinfo())
2094 +
            exit_code = e.exit_code
2095 +
        except RemoteRepository.RPCError as e:
2096 +
            msg = '%s\n%s' % (str(e), sysinfo())
2097 +
            exit_code = EXIT_ERROR
2098 +
        except Exception:
2099 +
            msg = 'Local Exception.\n%s\n%s' % (traceback.format_exc(), sysinfo())
2100 +
            exit_code = EXIT_ERROR
2101 +
        except KeyboardInterrupt:
2102 +
            msg = 'Keyboard interrupt.\n%s\n%s' % (traceback.format_exc(), sysinfo())
2103 +
            exit_code = EXIT_ERROR
2104 +
        except SigTerm:
2105 +
            msg = 'Received SIGTERM.'
2106 +
            exit_code = EXIT_ERROR
2107 +
        except SigHup:
2108 +
            msg = 'Received SIGHUP.'
2109 +
            exit_code = EXIT_ERROR
2110 +
        if msg:
2111 +
            logger.error(msg)
2112 +
        if args.show_rc:
2113 +
            exit_msg = 'terminating with %s status, rc %d'
2114 +
            if exit_code == EXIT_SUCCESS:
2115 +
                logger.info(exit_msg % ('success', exit_code))
2116 +
            elif exit_code == EXIT_WARNING:
2117 +
                logger.warning(exit_msg % ('warning', exit_code))
2118 +
            elif exit_code == EXIT_ERROR:
2119 +
                logger.error(exit_msg % ('error', exit_code))
2120 +
            else:
2121 +
                # if you see 666 in output, it usually means exit_code was None
2122 +
                logger.error(exit_msg % ('abnormal', exit_code or 666))
2123 +
        sys.exit(exit_code)
1716 2124
1717 2125
1718 2126
if __name__ == '__main__':
@@ -1719 +2127 @@
Loading

@@ -1,18 +1,19 @@
Loading
1 1
from configparser import ConfigParser
2 -
from binascii import hexlify, unhexlify
2 +
from binascii import unhexlify
3 3
from datetime import datetime
4 4
from itertools import islice
5 5
import errno
6 -
import logging
7 -
logger = logging.getLogger(__name__)
8 -
9 6
import os
10 7
import shutil
11 8
import struct
12 9
from zlib import crc32
13 10
14 11
import msgpack
15 -
from .helpers import Error, ErrorWithTraceback, IntegrityError, Location, ProgressIndicatorPercent
12 +
13 +
from .logger import create_logger
14 +
logger = create_logger()
15 +
16 +
from .helpers import Error, ErrorWithTraceback, IntegrityError, Location, ProgressIndicatorPercent, bin_to_hex
16 17
from .hashindex import NSIndex
17 18
from .locking import Lock, LockError, LockErrorT
18 19
from .lrucache import LRUCache
@@ -109,7 +110,7 @@
Loading
109 110
        config.set('repository', 'segments_per_dir', str(self.DEFAULT_SEGMENTS_PER_DIR))
110 111
        config.set('repository', 'max_segment_size', str(self.DEFAULT_MAX_SEGMENT_SIZE))
111 112
        config.set('repository', 'append_only', str(int(self.append_only)))
112 -
        config.set('repository', 'id', hexlify(os.urandom(32)).decode('ascii'))
113 +
        config.set('repository', 'id', bin_to_hex(os.urandom(32)))
113 114
        self.save_config(path, config)
114 115
115 116
    def save_config(self, path, config):
@@ -438,6 +439,9 @@
Loading
438 439
            transaction_id = self.get_index_transaction_id()
439 440
        if transaction_id is None:
440 441
            transaction_id = self.io.get_latest_segment()
442 +
        if transaction_id is None:
443 +
            report_error('This repository contains no valid data.')
444 +
            return False
441 445
        if repair:
442 446
            self.io.cleanup(transaction_id)
443 447
        segments_transaction_id = self.io.get_segments_transaction_id()
@@ -666,7 +670,8 @@
Loading
666 670
                if not os.path.exists(dirname):
667 671
                    os.mkdir(dirname)
668 672
                    sync_dir(os.path.join(self.path, 'data'))
669 -
            self._write_fd = open(self.segment_filename(self.segment), 'ab')
673 +
            # play safe: fail if file exists (do not overwrite existing contents, do not append)
674 +
            self._write_fd = open(self.segment_filename(self.segment), 'xb')
670 675
            self._write_fd.write(MAGIC)
671 676
            self.offset = MAGIC_LEN
672 677
        return self._write_fd
@@ -705,6 +710,13 @@
Loading
705 710
            else:
706 711
                yield tag, key, offset
707 712
            offset += size
713 +
            # we must get the fd via get_fd() here again as we yielded to our caller and it might
714 +
            # have triggered closing of the fd we had before (e.g. by calling io.read() for
715 +
            # different segment(s)).
716 +
            # by calling get_fd() here again we also make our fd "recently used" so it likely
717 +
            # does not get kicked out of self.fds LRUcache.
718 +
            fd = self.get_fd(segment)
719 +
            fd.seek(offset)
708 720
            header = fd.read(self.header_fmt.size)
709 721
710 722
    def recover_segment(self, segment, filename):
@@ -809,18 +821,35 @@
Loading
809 821
        self.close_segment()  # after-commit fsync()
810 822
811 823
    def close_segment(self):
812 -
        if self._write_fd:
813 -
            self.segment += 1
814 -
            self.offset = 0
815 -
            self._write_fd.flush()
816 -
            os.fsync(self._write_fd.fileno())
817 -
            if hasattr(os, 'posix_fadvise'):  # only on UNIX
818 -
                # tell the OS that it does not need to cache what we just wrote,
819 -
                # avoids spoiling the cache for the OS and other processes.
820 -
                os.posix_fadvise(self._write_fd.fileno(), 0, 0, os.POSIX_FADV_DONTNEED)
821 -
            self._write_fd.close()
822 -
            sync_dir(os.path.dirname(self._write_fd.name))
823 -
            self._write_fd = None
824 +
        # set self._write_fd to None early to guard against reentry from error handling code pathes:
825 +
        fd, self._write_fd = self._write_fd, None
826 +
        if fd is not None:
827 +
            dirname = None
828 +
            try:
829 +
                self.segment += 1
830 +
                self.offset = 0
831 +
                dirname = os.path.dirname(fd.name)
832 +
                fd.flush()
833 +
                fileno = fd.fileno()
834 +
                os.fsync(fileno)
835 +
                if hasattr(os, 'posix_fadvise'):  # only on UNIX
836 +
                    try:
837 +
                        # tell the OS that it does not need to cache what we just wrote,
838 +
                        # avoids spoiling the cache for the OS and other processes.
839 +
                        os.posix_fadvise(fileno, 0, 0, os.POSIX_FADV_DONTNEED)
840 +
                    except OSError:
841 +
                        # usually, posix_fadvise can't fail for us, but there seem to
842 +
                        # be failures when running borg under docker on ARM, likely due
843 +
                        # to a bug outside of borg.
844 +
                        # also, there is a python wrapper bug, always giving errno = 0.
845 +
                        # https://github.com/borgbackup/borg/issues/2095
846 +
                        # as this call is not critical for correct function (just to
847 +
                        # optimize cache usage), we ignore these errors.
848 +
                        pass
849 +
            finally:
850 +
                fd.close()
851 +
                if dirname:
852 +
                    sync_dir(dirname)
824 853
825 854
826 855
MAX_DATA_SIZE = MAX_OBJECT_SIZE - LoggedIO.put_header_fmt.size
@@ -827 +856 @@
Loading

@@ -0,0 +1,225 @@
Loading
1 +
from binascii import unhexlify, a2b_base64, b2a_base64
2 +
import binascii
3 +
import textwrap
4 +
from hashlib import sha256
5 +
import pkgutil
6 +
7 +
from .key import KeyfileKey, RepoKey, PassphraseKey, KeyfileNotFoundError, PlaintextKey
8 +
from .helpers import Manifest, NoManifestError, Error, yes, bin_to_hex
9 +
from .repository import Repository
10 +
11 +
12 +
class UnencryptedRepo(Error):
13 +
    """Keymanagement not available for unencrypted repositories."""
14 +
15 +
16 +
class UnknownKeyType(Error):
17 +
    """Keytype {0} is unknown."""
18 +
19 +
20 +
class RepoIdMismatch(Error):
21 +
    """This key backup seems to be for a different backup repository, aborting."""
22 +
23 +
24 +
class NotABorgKeyFile(Error):
25 +
    """This file is not a borg key backup, aborting."""
26 +
27 +
28 +
def sha256_truncated(data, num):
29 +
    h = sha256()
30 +
    h.update(data)
31 +
    return h.hexdigest()[:num]
32 +
33 +
34 +
KEYBLOB_LOCAL = 'local'
35 +
KEYBLOB_REPO = 'repo'
36 +
37 +
38 +
class KeyManager:
39 +
    def __init__(self, repository):
40 +
        self.repository = repository
41 +
        self.keyblob = None
42 +
        self.keyblob_storage = None
43 +
44 +
        try:
45 +
            cdata = self.repository.get(Manifest.MANIFEST_ID)
46 +
        except Repository.ObjectNotFound:
47 +
            raise NoManifestError
48 +
49 +
        key_type = cdata[0]
50 +
        if key_type == KeyfileKey.TYPE:
51 +
            self.keyblob_storage = KEYBLOB_LOCAL
52 +
        elif key_type == RepoKey.TYPE or key_type == PassphraseKey.TYPE:
53 +
            self.keyblob_storage = KEYBLOB_REPO
54 +
        elif key_type == PlaintextKey.TYPE:
55 +
            raise UnencryptedRepo()
56 +
        else:
57 +
            raise UnknownKeyType(key_type)
58 +
59 +
    def load_keyblob(self):
60 +
        if self.keyblob_storage == KEYBLOB_LOCAL:
61 +
            k = KeyfileKey(self.repository)
62 +
            target = k.find_key()
63 +
            with open(target, 'r') as fd:
64 +
                self.keyblob = ''.join(fd.readlines()[1:])
65 +
66 +
        elif self.keyblob_storage == KEYBLOB_REPO:
67 +
            self.keyblob = self.repository.load_key().decode()
68 +
69 +
    def store_keyblob(self, args):
70 +
        if self.keyblob_storage == KEYBLOB_LOCAL:
71 +
            k = KeyfileKey(self.repository)
72 +
            try:
73 +
                target = k.find_key()
74 +
            except KeyfileNotFoundError:
75 +
                target = k.get_new_target(args)
76 +
77 +
            self.store_keyfile(target)
78 +
        elif self.keyblob_storage == KEYBLOB_REPO:
79 +
            self.repository.save_key(self.keyblob.encode('utf-8'))
80 +
81 +
    def get_keyfile_data(self):
82 +
        data = '%s %s\n' % (KeyfileKey.FILE_ID, bin_to_hex(self.repository.id))
83 +
        data += self.keyblob
84 +
        if not self.keyblob.endswith('\n'):
85 +
            data += '\n'
86 +
        return data
87 +
88 +
    def store_keyfile(self, target):
89 +
        with open(target, 'w') as fd:
90 +
            fd.write(self.get_keyfile_data())
91 +
92 +
    def export(self, path):
93 +
        self.store_keyfile(path)
94 +
95 +
    def export_qr(self, path):
96 +
        with open(path, 'wb') as fd:
97 +
            key_data = self.get_keyfile_data()
98 +
            html = pkgutil.get_data('borg', 'paperkey.html')
99 +
            html = html.replace(b'</textarea>', key_data.encode() + b'</textarea>')
100 +
            fd.write(html)
101 +
102 +
    def export_paperkey(self, path):
103 +
        def grouped(s):
104 +
            ret = ''
105 +
            i = 0
106 +
            for ch in s:
107 +
                if i and i % 6 == 0:
108 +
                    ret += ' '
109 +
                ret += ch
110 +
                i += 1
111 +
            return ret
112 +
113 +
        export = 'To restore key use borg key import --paper /path/to/repo\n\n'
114 +
115 +
        binary = a2b_base64(self.keyblob)
116 +
        export += 'BORG PAPER KEY v1\n'
117 +
        lines = (len(binary) + 17) // 18
118 +
        repoid = bin_to_hex(self.repository.id)[:18]
119 +
        complete_checksum = sha256_truncated(binary, 12)
120 +
        export += 'id: {0:d} / {1} / {2} - {3}\n'.format(lines,
121 +
                                       grouped(repoid),
122 +
                                       grouped(complete_checksum),
123 +
                                       sha256_truncated((str(lines) + '/' + repoid + '/' + complete_checksum).encode('ascii'), 2))
124 +
        idx = 0
125 +
        while len(binary):
126 +
            idx += 1
127 +
            binline = binary[:18]
128 +
            checksum = sha256_truncated(idx.to_bytes(2, byteorder='big') + binline, 2)
129 +
            export += '{0:2d}: {1} - {2}\n'.format(idx, grouped(bin_to_hex(binline)), checksum)
130 +
            binary = binary[18:]
131 +
132 +
        if path:
133 +
            with open(path, 'w') as fd:
134 +
                fd.write(export)
135 +
        else:
136 +
            print(export)
137 +
138 +
    def import_keyfile(self, args):
139 +
        file_id = KeyfileKey.FILE_ID
140 +
        first_line = file_id + ' ' + bin_to_hex(self.repository.id) + '\n'
141 +
        with open(args.path, 'r') as fd:
142 +
            file_first_line = fd.read(len(first_line))
143 +
            if file_first_line != first_line:
144 +
                if not file_first_line.startswith(file_id):
145 +
                    raise NotABorgKeyFile()
146 +
                else:
147 +
                    raise RepoIdMismatch()
148 +
            self.keyblob = fd.read()
149 +
150 +
        self.store_keyblob(args)
151 +
152 +
    def import_paperkey(self, args):
153 +
        # imported here because it has global side effects
154 +
        import readline
155 +
156 +
        repoid = bin_to_hex(self.repository.id)[:18]
157 +
        try:
158 +
            while True:  # used for repeating on overall checksum mismatch
159 +
                # id line input
160 +
                while True:
161 +
                    idline = input('id: ').replace(' ', '')
162 +
                    if idline == "":
163 +
                        if yes("Abort import? [yN]:"):
164 +
                            raise EOFError()
165 +
166 +
                    try:
167 +
                        (data, checksum) = idline.split('-')
168 +
                    except ValueError:
169 +
                        print("each line must contain exactly one '-', try again")
170 +
                        continue
171 +
                    try:
172 +
                        (id_lines, id_repoid, id_complete_checksum) = data.split('/')
173 +
                    except ValueError:
174 +
                        print("the id line must contain exactly three '/', try again")
175 +
                    if sha256_truncated(data.lower().encode('ascii'), 2) != checksum:
176 +
                        print('line checksum did not match, try same line again')
177 +
                        continue
178 +
                    try:
179 +
                        lines = int(id_lines)
180 +
                    except ValueError:
181 +
                        print('internal error while parsing length')
182 +
183 +
                    break
184 +
185 +
                if repoid != id_repoid:
186 +
                    raise RepoIdMismatch()
187 +
188 +
                result = b''
189 +
                idx = 1
190 +
                # body line input
191 +
                while True:
192 +
                    inline = input('{0:2d}: '.format(idx))
193 +
                    inline = inline.replace(' ', '')
194 +
                    if inline == "":
195 +
                        if yes("Abort import? [yN]:"):
196 +
                            raise EOFError()
197 +
                    try:
198 +
                        (data, checksum) = inline.split('-')
199 +
                    except ValueError:
200 +
                        print("each line must contain exactly one '-', try again")
201 +
                        continue
202 +
                    try:
203 +
                        part = unhexlify(data)
204 +
                    except binascii.Error:
205 +
                        print("only characters 0-9 and a-f and '-' are valid, try again")
206 +
                        continue
207 +
                    if sha256_truncated(idx.to_bytes(2, byteorder='big') + part, 2) != checksum:
208 +
                        print('line checksum did not match, try line {0} again'.format(idx))
209 +
                        continue
210 +
                    result += part
211 +
                    if idx == lines:
212 +
                        break
213 +
                    idx += 1
214 +
215 +
                if sha256_truncated(result, 12) != id_complete_checksum:
216 +
                    print('The overall checksum did not match, retry or enter a blank line to abort.')
217 +
                    continue
218 +
219 +
                self.keyblob = '\n'.join(textwrap.wrap(b2a_base64(result).decode('ascii'))) + '\n'
220 +
                self.store_keyblob(args)
221 +
                break
222 +
223 +
        except EOFError:
224 +
            print('\n - aborted')
225 +
            return
@@ -0 +226 @@
Loading

@@ -1,12 +1,12 @@
Loading
1 -
from binascii import hexlify
2 1
import datetime
3 -
import logging
4 -
logger = logging.getLogger(__name__)
5 2
import os
6 3
import shutil
7 4
import time
8 5
9 -
from .helpers import get_keys_dir, get_cache_dir, ProgressIndicatorPercent
6 +
from .logger import create_logger
7 +
logger = create_logger()
8 +
9 +
from .helpers import get_keys_dir, get_cache_dir, ProgressIndicatorPercent, bin_to_hex
10 10
from .locking import Lock
11 11
from .repository import Repository, MAGIC
12 12
from .key import KeyfileKey, KeyfileNotFoundError
@@ -188,8 +188,8 @@
Loading
188 188
        attic_cache_dir = os.environ.get('ATTIC_CACHE_DIR',
189 189
                                         os.path.join(os.path.expanduser('~'),
190 190
                                                      '.cache', 'attic'))
191 -
        attic_cache_dir = os.path.join(attic_cache_dir, hexlify(self.id).decode('ascii'))
192 -
        borg_cache_dir = os.path.join(get_cache_dir(), hexlify(self.id).decode('ascii'))
191 +
        attic_cache_dir = os.path.join(attic_cache_dir, bin_to_hex(self.id))
192 +
        borg_cache_dir = os.path.join(get_cache_dir(), bin_to_hex(self.id))
193 193
194 194
        def copy_cache_file(path):
195 195
            """copy the given attic cache path into the borg directory
@@ -263,7 +263,7 @@
Loading
263 263
           assume the repository has been opened by the archiver yet
264 264
        """
265 265
        get_keys_dir = cls.get_keys_dir
266 -
        id = hexlify(repository.id).decode('ascii')
266 +
        id = bin_to_hex(repository.id)
267 267
        keys_dir = get_keys_dir()
268 268
        if not os.path.exists(keys_dir):
269 269
            raise KeyfileNotFoundError(repository.path, keys_dir)
@@ -313,7 +313,7 @@
Loading
313 313
    @classmethod
314 314
    def find_key_file(cls, repository):
315 315
        get_keys_dir = cls.get_keys_dir
316 -
        id = hexlify(repository.id).decode('ascii')
316 +
        id = bin_to_hex(repository.id)
317 317
        keys_dir = get_keys_dir()
318 318
        if not os.path.exists(keys_dir):
319 319
            raise KeyfileNotFoundError(repository.path, keys_dir)
@@ -320 +320 @@
Loading

@@ -12,9 +12,6 @@
Loading
12 12
13 13
from .helpers import Buffer
14 14
15 -
from .logger import create_logger
16 -
logger = create_logger()
17 -
18 15
19 16
try:
20 17
    ENOATTR = errno.ENOATTR
@@ -68,7 +65,7 @@
Loading
68 65
        libc_name = 'libc.dylib'
69 66
    else:
70 67
        msg = "Can't find C library. No fallback known. Try installing ldconfig, gcc/cc or objdump."
71 -
        logger.error(msg)
68 +
        print(msg, file=sys.stderr)  # logger isn't initialized at this stage
72 69
        raise Exception(msg)
73 70
74 71
# If we are running with fakeroot on Linux, then use the xattr functions of fakeroot. This is needed by
@@ -117,7 +114,7 @@
Loading
117 114
118 115
119 116
class BufferTooSmallError(Exception):
120 -
    """the buffer given to an xattr function was too small for the result"""
117 +
    """the buffer given to an xattr function was too small for the result."""
121 118
122 119
123 120
def _check(rv, path=None, detect_buffer_too_small=False):
@@ -208,7 +205,7 @@
Loading
208 205
209 206
        n, buf = _listxattr_inner(func, path)
210 207
        return [os.fsdecode(name) for name in split_string0(buf[:n])
211 -
                if not name.startswith(b'system.posix_acl_')]
208 +
                if name and not name.startswith(b'system.posix_acl_')]
212 209
213 210
    def getxattr(path, name, *, follow_symlinks=True):
214 211
        def func(path, name, buf, size):
@@ -264,7 +261,7 @@
Loading
264 261
                    return libc.listxattr(path, buf, size, XATTR_NOFOLLOW)
265 262
266 263
        n, buf = _listxattr_inner(func, path)
267 -
        return [os.fsdecode(name) for name in split_string0(buf[:n])]
264 +
        return [os.fsdecode(name) for name in split_string0(buf[:n]) if name]
268 265
269 266
    def getxattr(path, name, *, follow_symlinks=True):
270 267
        def func(path, name, buf, size):
@@ -323,7 +320,7 @@
Loading
323 320
                    return libc.extattr_list_link(path, ns, buf, size)
324 321
325 322
        n, buf = _listxattr_inner(func, path)
326 -
        return [os.fsdecode(name) for name in split_lstring(buf[:n])]
323 +
        return [os.fsdecode(name) for name in split_lstring(buf[:n]) if name]
327 324
328 325
    def getxattr(path, name, *, follow_symlinks=True):
329 326
        def func(path, name, buf, size):
@@ -330 +327 @@
Loading

Learn more Showing 5 files with coverage changes found.

New file borg/keymanager.py
New
Loading file...
Changes in borg/remote.py
-1
+1
Loading file...
Changes in borg/key.py
-1
-1
+2
Loading file...
Changes in borg/helpers.py
-14
+234
+6
Loading file...
Changes in borg/archiver.py
-32
+228
+7
Loading file...

251 Commits

-2
+1
+1
+663
+670
+9
-16
Hiding 3 contexual commits
+67
-6
-61
Hiding 2 contexual commits
-58
+4
+54
Hiding 2 contexual commits
+13
+6
+1
+6
Hiding 1 contexual commits
+5
+3
+1
+1
+2
-1
-1
-5
-5
+5
+5
+66
-5
-61
-68
+6
+62
+2
-1
-1
Hiding 1 contexual commits
+7
+4
+1
+2
Hiding 1 contexual commits
-2
+1
+1
+2
-1
-1
-2
+1
+1
+2
-1
-1
+1
-1
+1
+1
Hiding 1 contexual commits
+1
+3
-1
-1
-2
+1
+1
+2
-1
-1
Hiding 1 contexual commits
-2
+1
+1
-90
-94
+4
+91
+95
-4
Hiding 1 contexual commits
+2
-1
-1
+65
-7
-58
Hiding 1 contexual commits
-65
+7
+58
+65
-7
-58
-65
+7
+58
Hiding 1 contexual commits
+65
-5
-60
-2
+2
Hiding 2 contexual commits
+2
-2
-65
+5
+60
Hiding 1 contexual commits
-2
+1
+1
+2
-1
-1
Hiding 1 contexual commits
+65
-5
-60
Hiding 1 contexual commits
-65
+5
+60
Hiding 1 contexual commits Hiding 1 contexual commits
+65
-7
-58
-97
-163
+11
+55
-3
+1
+2
+97
+101
-5
+1
Hiding 4 contexual commits
+11
+10
+1
Hiding 2 contexual commits
+13
+6
+7
Hiding 1 contexual commits
+3
-1
-2
+8
-3
+3
+8
Hiding 2 contexual commits
-191
-203
+7
+5
+191
+201
-6
-4
Hiding 1 contexual commits
+2
-1
-1
Hiding 1 contexual commits
+68
-8
-60
Hiding 3 contexual commits
-1
+4
-5
+4
-2
+1
+5
Hiding 1 contexual commits
+2
-1
-1
Hiding 2 contexual commits
+8
+1
+7
Hiding 1 contexual commits
+2
+1
+1
Hiding 1 contexual commits
+1
+1
Hiding 7 contexual commits
+156
+143
+10
+3
Hiding 1 contexual commits
+1
+1
-2
+1
+1
+2
-1
-1
-2
+1
+1
+2
+3
-1
Hiding 4 contexual commits
+4
+4
+5
+3
+2
+1
+1
Hiding 1 contexual commits Hiding 1 contexual commits
-2
+1
+1
+2
-1
-1
+11
+14
-1
-2
Hiding 1 contexual commits
-11
-14
+1
+2
Hiding 1 contexual commits
+11
+14
-1
-2
Hiding 1 contexual commits
+2
+2
+1
+1
Hiding 2 contexual commits
-2
+1
+1
+2
-1
-1
Hiding 3 contexual commits Hiding 1 contexual commits
+8
+6
+2
Hiding 2 contexual commits
+2
+1
+1
Hiding 4 contexual commits
+10
+8
+2
Hiding 2 contexual commits
+8
+3
+3
+2
Hiding 1 contexual commits
+6
+3
+1
+2
-2
-2
+1
-1
-67
+7
+60
+67
-7
-60
-67
+7
+60
Hiding 4 contexual commits
+3
-1
-2
+64
-7
-57
-67
+8
+59
+67
-8
-59
Hiding 2 contexual commits
-69
+9
+60
+2
-1
-1
Hiding 3 contexual commits
+1 Files
+300
+223
+12
+65
Files Coverage
borg +1.67% 83.88%
Project Totals (15 files) 83.88%
Loading