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
f32c885
... +249 ...
e5f7121
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
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 |
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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]): |
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 |
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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) |
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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): |
| 1 | 1 | import errno |
|
| 2 | 2 | import os |
|
| 3 | + | import subprocess |
|
| 3 | 4 | import sys |
|
| 4 | 5 | ||
| 5 | 6 |
| 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 |
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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__': |
| 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 | 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 | 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 | 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 | 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 | 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 |
| 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 |
| 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 | 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 | 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 | 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) |
| 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 | 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 | 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 | 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 | 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 | 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): |
Learn more Showing 5 files with coverage changes found.
borg/keymanager.py
borg/remote.py
borg/key.py
borg/helpers.py
borg/archiver.py
| Files | Coverage |
|---|---|
| borg | +1.67% 83.88% |
| Project Totals (15 files) | 83.88% |
e5f7121bb6260333aeb22e208d1163045561cf58fb32acaff0d20a0dcf1f409e635f21c688b1bd3a2f364f9dd98257e55f52e84a657bc1ff455a9c0bd8be265479ef42209b9cacc44a16b8cf0ad23dbb4e6c19316252614dc349262c1751cadd38e85a39d5c213e7b7f74b533127250c2cfaf03528891a4e21715fbaefc96996fa6cf0192c8fe047eb6191ecfc8be58d350e3a739578e5f5b1b2fafd5e090ae907ca0c1daddd9d770b2321a74c3346bdab5de7b9ff752b6e8a1c44d9adcdffd93e022cf19e8af73f2662993b8beb994e35fccc69d400e1f050c716df452c2784a9cd6a0555c6a9c0fb8dae0094f7c3e4d7a8e9cf5d0a15530dedc4c0b4bb21b32e58e885b3625aea130500c7a4f82fd84ea488d3968dd61f986740b022c1281c854b993d7d3c941b8d79c42a753c0a9039e692d46603133207a2112d2bff9b6fa862b206aa701090d253aaee36075d54fe6b03a1c1a2891667926c51726433be58369b816f6cb6984e119042370cb1f2938a5fa1d223c5ed6d21853cfb7e61de12a84466d320a561be8e0c8c412b867d4d7e7953349373795f54b9a9f97519bf86a5b3019d7ec9a3e04fa985e79f960d33b8c2c31aa0ff76bdd5bc486c54a9123362ec3580599c4d6141aec4f42c1c55930f2f50ef28ad779343387be28b4700b32524af923e249c0571baa8baa445365bf5d6093a8d921a2dc558a420c9849396aac20d3eff61370086b6ddec4f0c2ab04340ae7f63ca85a4087060bbd7ad138548292ff4260b6f5ac6017ab796ea9ebd8b4a4335d5994affad7dfd37d07eb2dff288cac71c261f68ddbc452a340bfd6d3f27146d58671775bacdd9891989b228b25de0acd50e28baab5199e760a6619cb122780a83a49fc6f104e9f3bf3a1f064428daa2505515de31f5c920a1691fa568bd0e140cc15806373d8e89643e648465979b808d959451ab630d1e2120798df76c5f1acc20c98d56683ffbada181dc51e66bb363cc8b58e0df5482dcabcbc51fc368c5fe5b6bd8e806a3d174aed7284a9ea6515de17fe2bd871efc9cf4846c055dd2c81f861cda88e1372ebb44b5a17ca898297639eba19b1241e226170964a3fa83789c78960c4211a0218b3237db45c98b85cd8dfda19f6f7f3ee01975fecac6b026c8d74ab7f1cc499fe19891d694ff495adfd66d365ec85057ad07ec0ef2eaf23eea45c76c1319ecd80cda9d619ae2a7b4d0388c5f5d17f32c885