From 62ad0369efd083191793d0d026a24521b8f5cbb9 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 9 Nov 2023 06:00:13 +0100 Subject: [PATCH] update "modern" error RCs (docs and code) --- docs/internals/frontends.rst | 210 ++++++++++++++++-------- docs/usage/general/environment.rst.inc | 3 + docs/usage/general/return-codes.rst.inc | 6 +- src/borg/archive.py | 9 +- src/borg/archiver.py | 112 +++++-------- src/borg/cache.py | 23 ++- src/borg/constants.py | 7 +- src/borg/crypto/file_integrity.py | 1 + src/borg/crypto/key.py | 23 ++- src/borg/crypto/keymanager.py | 20 ++- src/borg/helpers/errors.py | 24 ++- src/borg/helpers/manifest.py | 10 +- src/borg/helpers/parseformat.py | 2 + src/borg/locking.py | 14 +- src/borg/remote.py | 7 + src/borg/repository.py | 45 +++-- src/borg/testsuite/archiver.py | 68 ++++++-- 17 files changed, 371 insertions(+), 213 deletions(-) diff --git a/docs/internals/frontends.rst b/docs/internals/frontends.rst index e36747f28..ac52721af 100644 --- a/docs/internals/frontends.rst +++ b/docs/internals/frontends.rst @@ -538,92 +538,164 @@ Message IDs are strings that essentially give a log message or operation a name, full text, since texts change more frequently. Message IDs are unambiguous and reduce the need to parse log messages. -Assigned message IDs are: +Assigned message IDs and related error RCs (exit codes) are: .. See scripts/errorlist.py; this is slightly edited. Errors - Archive.AlreadyExists - Archive {} already exists - Archive.DoesNotExist - Archive {} does not exist - Archive.IncompatibleFilesystemEncodingError - Failed to encode filename "{}" into file system encoding "{}". Consider configuring the LANG environment variable. - Cache.CacheInitAbortedError - Cache initialization aborted - Cache.EncryptionMethodMismatch - Repository encryption method changed since last access, refusing to continue - Cache.RepositoryAccessAborted - Repository access aborted - Cache.RepositoryIDNotUnique - Cache is newer than repository - do you have multiple, independently updated repos with same ID? - Cache.RepositoryReplay - Cache is newer than repository - this is either an attack or unsafe (multiple repos with same ID) - Buffer.MemoryLimitExceeded + Error rc: 2 traceback: no + Error: {} + ErrorWithTraceback rc: 2 traceback: yes + Error: {} + + ExtensionModuleError rc: 2 traceback: no + The Borg binary extension modules do not seem to be properly installed. + PythonLibcTooOld rc: 2 traceback: no + FATAL: this Python was compiled for a too old (g)libc and misses required functionality. + Buffer.MemoryLimitExceeded rc: 2 traceback: no Requested buffer size {} is above the limit of {}. - ExtensionModuleError - The Borg binary extension modules do not seem to be properly installed - IntegrityError - Data integrity error: {} - NoManifestError - Repository has no manifest. - PlaceholderError + EfficientCollectionQueue.SizeUnderflow rc: 2 traceback: no + Could not pop_front first {} elements, collection only has {} elements.. + RTError rc: 2 traceback: no + Runtime Error: {} + + CancelledByUser rc: 3 traceback: no + Cancelled by user. + + CommandError rc: 4 traceback: no + Command Error: {} + PlaceholderError rc: 5 traceback: no Formatting Error: "{}".format({}): {}({}) - KeyfileInvalidError - Invalid key file for repository {} found in {}. - KeyfileMismatchError - Mismatch between repository {} and key file {}. - KeyfileNotFoundError - No key file for repository {} found in {}. - PassphraseWrong - passphrase supplied in BORG_PASSPHRASE is incorrect - PasswordRetriesExceeded - exceeded the maximum password retries - RepoKeyNotFoundError - No key entry found in the config of repository {}. - UnsupportedManifestError + InvalidPlaceholder rc: 6 traceback: no + Invalid placeholder "{}" in string: {} + + Repository.AlreadyExists rc: 10 traceback: no + A repository already exists at {}. + Repository.AtticRepository rc: 11 traceback: no + Attic repository detected. Please run "borg upgrade {}". + Repository.CheckNeeded rc: 12 traceback: yes + Inconsistency detected. Please run "borg check {}". + Repository.DoesNotExist rc: 13 traceback: no + Repository {} does not exist. + Repository.InsufficientFreeSpaceError rc: 14 traceback: no + Insufficient free space to complete transaction (required: {}, available: {}). + Repository.InvalidRepository rc: 15 traceback: no + {} is not a valid repository. Check repo config. + Repository.InvalidRepositoryConfig rc: 16 traceback: no + {} does not have a valid configuration. Check repo config [{}]. + Repository.ObjectNotFound rc: 17 traceback: yes + Object with key {} not found in repository {}. + Repository.ParentPathDoesNotExist rc: 18 traceback: no + The parent path of the repo directory [{}] does not exist. + Repository.PathAlreadyExists rc: 19 traceback: no + There is already something at {}. + Repository.StorageQuotaExceeded rc: 20 traceback: no + The storage quota ({}) has been exceeded ({}). Try deleting some archives. + + MandatoryFeatureUnsupported rc: 25 traceback: no + Unsupported repository feature(s) {}. A newer version of borg is required to access this repository. + NoManifestError rc: 26 traceback: no + Repository has no manifest. + UnsupportedManifestError rc: 27 traceback: no Unsupported manifest envelope. A newer version is required to access this repository. - UnsupportedPayloadError - Unsupported payload type {}. A newer version is required to access this repository. - NotABorgKeyFile + + Archive.AlreadyExists rc: 30 traceback: no + Archive {} already exists + Archive.DoesNotExist rc: 31 traceback: no + Archive {} does not exist + Archive.IncompatibleFilesystemEncodingError rc: 32 traceback: no + Failed to encode filename "{}" into file system encoding "{}". Consider configuring the LANG environment variable. + + KeyfileInvalidError rc: 40 traceback: no + Invalid key file for repository {} found in {}. + KeyfileMismatchError rc: 41 traceback: no + Mismatch between repository {} and key file {}. + KeyfileNotFoundError rc: 42 traceback: no + No key file for repository {} found in {}. + NotABorgKeyFile rc: 43 traceback: no This file is not a borg key backup, aborting. - RepoIdMismatch + RepoKeyNotFoundError rc: 44 traceback: no + No key entry found in the config of repository {}. + RepoIdMismatch rc: 45 traceback: no This key backup seems to be for a different backup repository, aborting. - UnencryptedRepo - Keymanagement not available for unencrypted repositories. - UnknownKeyType - Keytype {0} is unknown. - LockError + UnencryptedRepo rc: 46 traceback: no + Key management not available for unencrypted repositories. + UnknownKeyType rc: 47 traceback: no + Key type {0} is unknown. + UnsupportedPayloadError rc: 48 traceback: no + Unsupported payload type {}. A newer version is required to access this repository. + + NoPassphraseFailure rc: 50 traceback: no + can not acquire a passphrase: {} + PasscommandFailure rc: 51 traceback: no + passcommand supplied in BORG_PASSCOMMAND failed: {} + PassphraseWrong rc: 52 traceback: no + passphrase supplied in BORG_PASSPHRASE, by BORG_PASSCOMMAND or via BORG_PASSPHRASE_FD is incorrect. + PasswordRetriesExceeded rc: 53 traceback: no + exceeded the maximum password retries + + Cache.CacheInitAbortedError rc: 60 traceback: no + Cache initialization aborted + Cache.EncryptionMethodMismatch rc: 61 traceback: no + Repository encryption method changed since last access, refusing to continue + Cache.RepositoryAccessAborted rc: 62 traceback: no + Repository access aborted + Cache.RepositoryIDNotUnique rc: 63 traceback: no + Cache is newer than repository - do you have multiple, independently updated repos with same ID? + Cache.RepositoryReplay rc: 64 traceback: no + Cache, or information obtained from the security directory is newer than repository - this is either an attack or unsafe (multiple repos with same ID) + + LockError rc: 70 traceback: no Failed to acquire the lock {}. - LockErrorT + LockErrorT rc: 71 traceback: yes Failed to acquire the lock {}. - ConnectionClosed + LockFailed rc: 72 traceback: yes + Failed to create/acquire the lock {} ({}). + LockTimeout rc: 73 traceback: no + Failed to create/acquire the lock {} (timeout). + NotLocked rc: 74 traceback: yes + Failed to release the lock {} (was not locked). + NotMyLock rc: 75 traceback: yes + Failed to release the lock {} (was/is locked, but not by me). + + ConnectionClosed rc: 80 traceback: no Connection closed by remote host - InvalidRPCMethod + ConnectionClosedWithHint rc: 81 traceback: no + Connection closed by remote host. {} + InvalidRPCMethod rc: 82 traceback: no RPC method {} is not valid - PathNotAllowed - Repository path not allowed - RemoteRepository.RPCServerOutdated + PathNotAllowed rc: 83 traceback: no + Repository path not allowed: {} + RemoteRepository.RPCServerOutdated rc: 84 traceback: no Borg server is too old for {}. Required version {} - UnexpectedRPCDataFormatFromClient + UnexpectedRPCDataFormatFromClient rc: 85 traceback: no Borg {}: Got unexpected RPC data format from client. - UnexpectedRPCDataFormatFromServer + UnexpectedRPCDataFormatFromServer rc: 86 traceback: no Got unexpected RPC data format from server: {} - Repository.AlreadyExists - Repository {} already exists. - Repository.CheckNeeded - Inconsistency detected. Please run "borg check {}". - Repository.DoesNotExist - Repository {} does not exist. - Repository.InsufficientFreeSpaceError - Insufficient free space to complete transaction (required: {}, available: {}). - Repository.InvalidRepository - {} is not a valid repository. Check repo config. - Repository.AtticRepository - Attic repository detected. Please run "borg upgrade {}". - Repository.ObjectNotFound - Object with key {} not found in repository {}. + + IntegrityError rc: 90 traceback: yes + Data integrity error: {} + FileIntegrityError rc: 91 traceback: yes + File failed integrity check: {} + DecompressionError rc: 92 traceback: yes + Decompression error: {} + + ArchiveTAMInvalid rc: 95 traceback: yes + Data integrity error: {} + ArchiveTAMRequiredError rc: 96 traceback: yes + Archive '{}' is unauthenticated, but it is required for this repository. + TAMInvalid rc: 97 traceback: yes + Data integrity error: {} + TAMRequiredError rc: 98 traceback: yes + Manifest is unauthenticated, but it is required for this repository. + + This either means that you are under attack, or that you modified this repository + with a Borg version older than 1.0.9 after TAM authentication was enabled. + + In the latter case, use "borg upgrade --tam --force '{}'" to re-authenticate the manifest. + TAMUnsupportedSuiteError rc: 99 traceback: yes + Could not verify manifest: Unsupported suite {!r}; a newer version is needed. Operations - cache.begin_transaction diff --git a/docs/usage/general/environment.rst.inc b/docs/usage/general/environment.rst.inc index 6b067f55d..991281e6a 100644 --- a/docs/usage/general/environment.rst.inc +++ b/docs/usage/general/environment.rst.inc @@ -35,6 +35,9 @@ General: Main usecase for this is to fully automate ``borg change-passphrase``. BORG_DISPLAY_PASSPHRASE When set, use the value to answer the "display the passphrase for verification" question when defining a new passphrase for encrypted repositories. + BORG_EXIT_CODES + When set to "modern", the borg process will return more specific exit codes (rc). + Default is "legacy" and returns rc 2 for all errors, 1 for all warnings, 0 for success. BORG_HOST_ID Borg usually computes a host id from the FQDN plus the results of ``uuid.getnode()`` (which usually returns a unique id based on the MAC address of the network interface. Except if that MAC happens to be all-zero - in diff --git a/docs/usage/general/return-codes.rst.inc b/docs/usage/general/return-codes.rst.inc index 68f458c4d..e908c9283 100644 --- a/docs/usage/general/return-codes.rst.inc +++ b/docs/usage/general/return-codes.rst.inc @@ -7,10 +7,12 @@ Borg can exit with the following return codes (rc): Return code Meaning =========== ======= 0 success (logged as INFO) -1 warning (operation reached its normal end, but there were warnings -- +1 generic warning (operation reached its normal end, but there were warnings -- you should check the log, logged as WARNING) -2 error (like a fatal error, a local or remote exception, the operation +2 generic error (like a fatal error, a local or remote exception, the operation did not reach its normal end, logged as ERROR) +3..99 specific error (enabled by BORG_EXIT_CODES=modern) +100..127 specific warning (enabled by BORG_EXIT_CODES=modern) 128+N killed by signal N (e.g. 137 == kill -9) =========== ======= diff --git a/src/borg/archive.py b/src/borg/archive.py index 359c55053..6af74c6d1 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -424,14 +424,17 @@ def get_item_uid_gid(item, *, numeric, uid_forced=None, gid_forced=None, uid_def class Archive: - class DoesNotExist(Error): - """Archive {} does not exist""" - class AlreadyExists(Error): """Archive {} already exists""" + exit_mcode = 30 + + class DoesNotExist(Error): + """Archive {} does not exist""" + exit_mcode = 31 class IncompatibleFilesystemEncodingError(Error): """Failed to encode filename "{}" into file system encoding "{}". Consider configuring the LANG environment variable.""" + exit_mcode = 32 def __init__(self, repository, key, manifest, name, cache=None, create=False, checkpoint_interval=1800, numeric_ids=False, noatime=False, noctime=False, diff --git a/src/borg/archiver.py b/src/borg/archiver.py index 5ece677e7..b21c86b8c 100644 --- a/src/borg/archiver.py +++ b/src/borg/archiver.py @@ -46,7 +46,7 @@ try: from .crypto.key import key_creator, key_argument_names, tam_required_file, tam_required, RepoKey, PassphraseKey from .crypto.keymanager import KeyManager from .helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, EXIT_SIGNAL_BASE - from .helpers import Error, NoManifestError, set_ec + from .helpers import Error, NoManifestError, CancelledByUser, RTError, CommandError, modern_ec, set_ec from .helpers import positive_int_validator, location_validator, archivename_validator, ChunkerParams, Location from .helpers import PrefixSpec, GlobSpec, CommentSpec, PathSpec, SortBySpec, FilesCacheMode from .helpers import BaseFormatter, ItemFormatter, ArchiveFormatter @@ -240,11 +240,6 @@ class Archiver: self.prog = prog self.last_checkpoint = time.monotonic() - def print_error(self, msg, *args): - msg = args and msg % args or msg - self.exit_code = EXIT_ERROR - logger.error(msg) - def print_warning(self, msg, *args): msg = args and msg % args or msg self.exit_code = EXIT_WARNING # we do not terminate here, so it is a warning @@ -330,21 +325,18 @@ class Archiver: if not yes(msg, false_msg="Aborting.", invalid_msg="Invalid answer, aborting.", truish=('YES', ), retry=False, env_var_override='BORG_CHECK_I_KNOW_WHAT_I_AM_DOING'): - return EXIT_ERROR + raise CancelledByUser() if args.repo_only and any( (args.verify_data, args.first, args.last, args.prefix is not None, args.glob_archives)): - self.print_error("--repository-only contradicts --first, --last, --glob-archives, --prefix and --verify-data arguments.") - return EXIT_ERROR + raise CommandError("--repository-only contradicts --first, --last, --glob-archives, --prefix and --verify-data arguments.") if args.repair and args.max_duration: - self.print_error("--repair does not allow --max-duration argument.") - return EXIT_ERROR + raise CommandError("--repair does not allow --max-duration argument.") if args.max_duration and not args.repo_only: # when doing a partial repo check, we can only check crc32 checksums in segment files, # we can't build a fresh repo index in memory to verify the on-disk index against it. # thus, we should not do an archives check based on a unknown-quality on-disk repo index. # also, there is no max_duration support in the archives check code anyway. - self.print_error("--repository-only is required for --max-duration support.") - return EXIT_ERROR + raise CommandError("--repository-only is required for --max-duration support.") if not args.archives_only: if not repository.check(repair=args.repair, save_space=args.save_space, max_duration=args.max_duration): return EXIT_WARNING @@ -361,8 +353,7 @@ class Archiver: def do_change_passphrase(self, args, repository, manifest, key): """Change repository key file passphrase""" if not hasattr(key, 'change_passphrase'): - print('This repository is not encrypted, cannot change the passphrase.') - return EXIT_ERROR + raise CommandError('This repository is not encrypted, cannot change the passphrase.') key.change_passphrase() logger.info('Key updated') if hasattr(key, 'find_key'): @@ -384,8 +375,7 @@ class Archiver: else: manager.export(args.path) except IsADirectoryError: - self.print_error(f"'{args.path}' must be a file, not a directory") - return EXIT_ERROR + raise CommandError(f"'{args.path}' must be a file, not a directory") return EXIT_SUCCESS @with_repository(lock=False, exclusive=False, manifest=False, cache=False) @@ -394,16 +384,13 @@ class Archiver: manager = KeyManager(repository) if args.paper: if args.path: - self.print_error("with --paper import from file is not supported") - return EXIT_ERROR + raise CommandError("with --paper import from file is not supported") manager.import_paperkey(args) else: if not args.path: - self.print_error("input file to import key from expected") - return EXIT_ERROR + raise CommandError("expected input file to import key from") if args.path != '-' and not os.path.exists(args.path): - self.print_error("input file does not exist: " + args.path) - return EXIT_ERROR + raise CommandError("input file does not exist: " + args.path) manager.import_keyfile(args) return EXIT_SUCCESS @@ -536,16 +523,13 @@ class Archiver: env = prepare_subprocess_env(system=True) proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=ignore_sigint) except (FileNotFoundError, PermissionError) as e: - self.print_error('Failed to execute command: %s', e) - return self.exit_code + raise CommandError('Failed to execute command: %s', e) status = fso.process_pipe(path=path, cache=cache, fd=proc.stdout, mode=mode, user=user, group=group) rc = proc.wait() if rc != 0: - self.print_error('Command %r exited with status %d', args.paths[0], rc) - return self.exit_code + raise CommandError('Command %r exited with status %d', args.paths[0], rc) except BackupOSError as e: - self.print_error('%s: %s', path, e) - return self.exit_code + raise Error('%s: %s', path, e) else: status = '-' self.print_file_status(status, path) @@ -556,8 +540,7 @@ class Archiver: env = prepare_subprocess_env(system=True) proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=ignore_sigint) except (FileNotFoundError, PermissionError) as e: - self.print_error('Failed to execute command: %s', e) - return self.exit_code + raise CommandError('Failed to execute command: %s', e) pipe_bin = proc.stdout else: # args.paths_from_stdin == True pipe_bin = sys.stdin.buffer @@ -578,8 +561,7 @@ class Archiver: if args.paths_from_command: rc = proc.wait() if rc != 0: - self.print_error('Command %r exited with status %d', args.paths[0], rc) - return self.exit_code + raise CommandError('Command %r exited with status %d', args.paths[0], rc) else: for path in args.paths: if path == '-': # stdin @@ -621,7 +603,7 @@ class Archiver: if sig_int: # do not save the archive if the user ctrl-c-ed - it is valid, but incomplete. # we already have a checkpoint archive in this case. - self.print_error("Got Ctrl-C / SIGINT.") + raise Error("Got Ctrl-C / SIGINT.") else: archive.save(comment=args.comment, timestamp=args.timestamp) args.stats |= args.json @@ -1189,8 +1171,7 @@ class Archiver: explicit_archives_specified = args.location.archive or args.archives self.output_list = args.output_list if archive_filter_specified and explicit_archives_specified: - self.print_error('Mixing archive filters and explicitly named archives is not supported.') - return self.exit_code + raise CommandError('Mixing archive filters and explicitly named archives is not supported.') if archive_filter_specified or explicit_archives_specified: return self._delete_archives(args, repository) else: @@ -1270,7 +1251,7 @@ class Archiver: uncommitted_deletes = 0 if checkpointed else (uncommitted_deletes + 1) if sig_int: # Ctrl-C / SIGINT: do not checkpoint (commit) again, we already have a checkpoint in this case. - self.print_error("Got Ctrl-C / SIGINT.") + raise Error("Got Ctrl-C / SIGINT.") elif uncommitted_deletes > 0: checkpoint_func() if args.stats: @@ -1325,8 +1306,7 @@ class Archiver: msg = '\n'.join(msg) if not yes(msg, false_msg="Aborting.", invalid_msg='Invalid answer, aborting.', truish=('YES',), retry=False, env_var_override='BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'): - self.exit_code = EXIT_ERROR - return self.exit_code + raise CancelledByUser() if not dry_run: repository.destroy() logger.info("Repository deleted.") @@ -1348,12 +1328,10 @@ class Archiver: from .fuse_impl import llfuse, BORG_FUSE_IMPL if llfuse is None: - self.print_error('borg mount not available: no FUSE support, BORG_FUSE_IMPL=%s.' % BORG_FUSE_IMPL) - return self.exit_code + raise RTError('borg mount not available: no FUSE support, BORG_FUSE_IMPL=%s.' % BORG_FUSE_IMPL) if not os.path.isdir(args.mountpoint) or not os.access(args.mountpoint, os.R_OK | os.W_OK | os.X_OK): - self.print_error('%s: Mountpoint must be a writable directory' % args.mountpoint) - return self.exit_code + raise RTError('%s: Mount point must be a writable directory' % args.mountpoint) return self._do_mount(args) @@ -1368,7 +1346,7 @@ class Archiver: operations.mount(args.mountpoint, args.options, args.foreground) except RuntimeError: # Relevant error message already printed to stderr by FUSE - self.exit_code = EXIT_ERROR + raise RTError("FUSE mount failed") return self.exit_code def do_umount(self, args): @@ -1380,13 +1358,11 @@ class Archiver: """List archive or repository contents""" if args.location.archive: if args.json: - self.print_error('The --json option is only valid for listing archives, not archive contents.') - return self.exit_code + raise CommandError('The --json option is only valid for listing archives, not archive contents.') return self._list_archive(args, repository, manifest, key) else: if args.json_lines: - self.print_error('The --json-lines option is only valid for listing archive contents, not archives.') - return self.exit_code + raise CommandError('The --json-lines option is only valid for listing archive contents, not archives.') return self._list_repository(args, repository, manifest, key) def _list_archive(self, args, repository, manifest, key): @@ -1533,10 +1509,9 @@ class Archiver: """Prune repository archives according to specified rules""" if not any((args.secondly, args.minutely, args.hourly, args.daily, args.weekly, args.monthly, args.yearly, args.within)): - self.print_error('At least one of the "keep-within", "keep-last", ' - '"keep-secondly", "keep-minutely", "keep-hourly", "keep-daily", ' - '"keep-weekly", "keep-monthly" or "keep-yearly" settings must be specified.') - return self.exit_code + raise CommandError('At least one of the "keep-within", "keep-last", ' + '"keep-secondly", "keep-minutely", "keep-hourly", "keep-daily", ' + '"keep-weekly", "keep-monthly" or "keep-yearly" settings must be specified.') if args.prefix is not None: args.glob_archives = args.prefix + '*' checkpoint_re = r'\.checkpoint(\.\d+)?' @@ -1615,7 +1590,7 @@ class Archiver: pi.finish() if sig_int: # Ctrl-C / SIGINT: do not checkpoint (commit) again, we already have a checkpoint in this case. - self.print_error("Got Ctrl-C / SIGINT.") + raise Error("Got Ctrl-C / SIGINT.") elif uncommitted_deletes > 0: checkpoint_func() if args.stats: @@ -1722,15 +1697,13 @@ class Archiver: if args.location.archive: name = args.location.archive if recreater.is_temporary_archive(name): - self.print_error('Refusing to work on temporary archive of prior recreate: %s', name) - return self.exit_code + raise CommandError('Refusing to work on temporary archive of prior recreate: %s', name) if not recreater.recreate(name, args.comment, args.target): - self.print_error('Nothing to do. Archive was not processed.\n' - 'Specify at least one pattern, PATH, --comment, re-compression or re-chunking option.') + raise CommandError('Nothing to do. Archive was not processed.\n' + 'Specify at least one pattern, PATH, --comment, re-compression or re-chunking option.') else: if args.target is not None: - self.print_error('--target: Need to specify single archive') - return self.exit_code + raise CommandError('--target: Need to specify single archive') for archive in manifest.archives.list(sort_by=['ts']): name = archive.name if recreater.is_temporary_archive(name): @@ -1945,8 +1918,7 @@ class Archiver: if not args.list: if args.name is None: - self.print_error('No config key name was provided.') - return self.exit_code + raise CommandError('No config key name was provided.') try: section, name = args.name.split('.') @@ -2158,8 +2130,7 @@ class Archiver: except (ValueError, UnicodeEncodeError): wanted = None if not wanted: - self.print_error('search term needs to be hex:123abc or str:foobar style') - return EXIT_ERROR + raise CommandError('search term needs to be hex:123abc or str:foobar style') from .crypto.key import key_factory # set up the key without depending on a manifest obj @@ -2212,13 +2183,11 @@ class Archiver: if len(id) != 32: # 256bit raise ValueError("id must be 256bits or 64 hex digits") except ValueError as err: - print(f"object id {hex_id} is invalid [{str(err)}].") - return EXIT_ERROR + raise CommandError(f"object id {hex_id} is invalid [{str(err)}].") try: data = repository.get(id) except Repository.ObjectNotFound: - print("object %s not found." % hex_id) - return EXIT_ERROR + raise RTError("object %s not found." % hex_id) with open(args.path, "wb") as f: f.write(data) print("object %s fetched." % hex_id) @@ -2244,8 +2213,7 @@ class Archiver: if len(id) != 32: # 256bit raise ValueError("id must be 256bits or 64 hex digits") except ValueError as err: - print(f"object id {hex_id} is invalid [{str(err)}].") - return EXIT_ERROR + raise CommandError(f"object id {hex_id} is invalid [{str(err)}].") repository.put(id, data) print("object %s put." % hex_id) repository.commit(compact=False) @@ -5330,7 +5298,7 @@ def main(): # pragma: no cover except argparse.ArgumentTypeError as e: # we might not have logging setup yet, so get out quickly print(str(e), file=sys.stderr) - sys.exit(EXIT_ERROR) + sys.exit(CommandError.exit_mcode if modern_ec else EXIT_ERROR) except Exception: msg = 'Local Exception' tb = f'{traceback.format_exc()}\n{sysinfo()}' @@ -5388,9 +5356,9 @@ def main(): # pragma: no cover exit_msg = 'terminating with %s status, rc %d' if exit_code == EXIT_SUCCESS: rc_logger.info(exit_msg % ('success', exit_code)) - elif exit_code == EXIT_WARNING: + elif exit_code == EXIT_WARNING or EXIT_WARNING_BASE <= exit_code < EXIT_SIGNAL_BASE: rc_logger.warning(exit_msg % ('warning', exit_code)) - elif exit_code == EXIT_ERROR: + elif exit_code == EXIT_ERROR or EXIT_ERROR_BASE <= exit_code < EXIT_WARNING_BASE: rc_logger.error(exit_msg % ('error', exit_code)) elif exit_code >= EXIT_SIGNAL_BASE: rc_logger.error(exit_msg % ('signal', exit_code)) diff --git a/src/borg/cache.py b/src/borg/cache.py index 523aedf93..66cf124f6 100644 --- a/src/borg/cache.py +++ b/src/borg/cache.py @@ -341,20 +341,25 @@ class CacheConfig: class Cache: """Client Side cache """ - class RepositoryIDNotUnique(Error): - """Cache is newer than repository - do you have multiple, independently updated repos with same ID?""" - - class RepositoryReplay(Error): - """Cache, or information obtained from the security directory is newer than repository - this is either an attack or unsafe (multiple repos with same ID)""" - class CacheInitAbortedError(Error): """Cache initialization aborted""" - - class RepositoryAccessAborted(Error): - """Repository access aborted""" + exit_mcode = 60 class EncryptionMethodMismatch(Error): """Repository encryption method changed since last access, refusing to continue""" + exit_mcode = 61 + + class RepositoryAccessAborted(Error): + """Repository access aborted""" + exit_mcode = 62 + + class RepositoryIDNotUnique(Error): + """Cache is newer than repository - do you have multiple, independently updated repos with same ID?""" + exit_mcode = 63 + + class RepositoryReplay(Error): + """Cache, or information obtained from the security directory is newer than repository - this is either an attack or unsafe (multiple repos with same ID)""" + exit_mcode = 64 @staticmethod def break_lock(repository, path=None): diff --git a/src/borg/constants.py b/src/borg/constants.py index c2ebd4d7c..322cc5579 100644 --- a/src/borg/constants.py +++ b/src/borg/constants.py @@ -93,10 +93,11 @@ FILES_CACHE_MODE_UI_DEFAULT = 'ctime,size,inode' # default for "borg create" co FILES_CACHE_MODE_DISABLED = 'd' # most borg commands do not use the files cache at all (disable) # return codes returned by borg command -# when borg is killed by signal N, rc = 128 + N EXIT_SUCCESS = 0 # everything done, no problems -EXIT_WARNING = 1 # reached normal end of operation, but there were issues -EXIT_ERROR = 2 # terminated abruptly, did not reach end of operation +EXIT_WARNING = 1 # reached normal end of operation, but there were issues (generic warning) +EXIT_ERROR = 2 # terminated abruptly, did not reach end of operation (generic error) +EXIT_ERROR_BASE = 3 # specific error codes are 3..99 (enabled by BORG_EXIT_CODES=modern) +EXIT_WARNING_BASE = 100 # specific warning codes are 100..127 (enabled by BORG_EXIT_CODES=modern) EXIT_SIGNAL_BASE = 128 # terminated due to signal, rc = 128 + sig_no # never use datetime.isoformat(), it is evil. always use one of these: diff --git a/src/borg/crypto/file_integrity.py b/src/borg/crypto/file_integrity.py index 76503057f..6ad2272ce 100644 --- a/src/borg/crypto/file_integrity.py +++ b/src/borg/crypto/file_integrity.py @@ -119,6 +119,7 @@ SUPPORTED_ALGORITHMS = { class FileIntegrityError(IntegrityError): """File failed integrity check: {}""" + exit_mcode = 91 class IntegrityCheckedFile(FileLikeWrapper): diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 6a79b7499..fbb7ee03b 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -39,42 +39,52 @@ AUTHENTICATED_NO_KEY = 'authenticated_no_key' in workarounds class NoPassphraseFailure(Error): """can not acquire a passphrase: {}""" - - -class PassphraseWrong(Error): - """passphrase supplied in BORG_PASSPHRASE, by BORG_PASSCOMMAND or via BORG_PASSPHRASE_FD is incorrect.""" + exit_mcode = 50 class PasscommandFailure(Error): """passcommand supplied in BORG_PASSCOMMAND failed: {}""" + exit_mcode = 51 + + +class PassphraseWrong(Error): + """passphrase supplied in BORG_PASSPHRASE, by BORG_PASSCOMMAND or via BORG_PASSPHRASE_FD is incorrect.""" + exit_mcode = 52 class PasswordRetriesExceeded(Error): """exceeded the maximum password retries""" + exit_mcode = 53 class UnsupportedPayloadError(Error): """Unsupported payload type {}. A newer version is required to access this repository.""" + exit_mcode = 48 class UnsupportedManifestError(Error): """Unsupported manifest envelope. A newer version is required to access this repository.""" + exit_mcode = 27 class KeyfileNotFoundError(Error): """No key file for repository {} found in {}.""" + exit_mcode = 42 class KeyfileInvalidError(Error): """Invalid key file for repository {} found in {}.""" + exit_mcode = 40 class KeyfileMismatchError(Error): """Mismatch between repository {} and key file {}.""" + exit_mcode = 41 class RepoKeyNotFoundError(Error): """No key entry found in the config of repository {}.""" + exit_mcode = 44 class TAMRequiredError(IntegrityError): @@ -87,6 +97,7 @@ class TAMRequiredError(IntegrityError): In the latter case, use "borg upgrade --tam --force '{}'" to re-authenticate the manifest. """).strip() traceback = True + exit_mcode = 98 class ArchiveTAMRequiredError(TAMRequiredError): @@ -94,11 +105,13 @@ class ArchiveTAMRequiredError(TAMRequiredError): Archive '{}' is unauthenticated, but it is required for this repository. """).strip() traceback = True + exit_mcode = 96 class TAMInvalid(IntegrityError): __doc__ = IntegrityError.__doc__ traceback = True + exit_mcode = 97 def __init__(self): # Error message becomes: "Data integrity error: Manifest authentication did not verify" @@ -108,6 +121,7 @@ class TAMInvalid(IntegrityError): class ArchiveTAMInvalid(IntegrityError): __doc__ = IntegrityError.__doc__ traceback = True + exit_mcode = 95 def __init__(self): # Error message becomes: "Data integrity error: Archive authentication did not verify" @@ -117,6 +131,7 @@ class ArchiveTAMInvalid(IntegrityError): class TAMUnsupportedSuiteError(IntegrityError): """Could not verify manifest: Unsupported suite {!r}; a newer version is needed.""" traceback = True + exit_mcode = 99 class KeyBlobStorage: diff --git a/src/borg/crypto/keymanager.py b/src/borg/crypto/keymanager.py index 2d41c3022..d19f800a9 100644 --- a/src/borg/crypto/keymanager.py +++ b/src/borg/crypto/keymanager.py @@ -10,20 +10,24 @@ from ..repository import Repository from .key import KeyfileKey, KeyfileNotFoundError, RepoKeyNotFoundError, KeyBlobStorage, identify_key -class UnencryptedRepo(Error): - """Keymanagement not available for unencrypted repositories.""" - - -class UnknownKeyType(Error): - """Keytype {0} is unknown.""" +class NotABorgKeyFile(Error): + """This file is not a borg key backup, aborting.""" + exit_mcode = 43 class RepoIdMismatch(Error): """This key backup seems to be for a different backup repository, aborting.""" + exit_mcode = 45 -class NotABorgKeyFile(Error): - """This file is not a borg key backup, aborting.""" +class UnencryptedRepo(Error): + """Key management not available for unencrypted repositories.""" + exit_mcode = 46 + + +class UnknownKeyType(Error): + """Key type {0} is unknown.""" + exit_mcode = 47 def sha256_truncated(data, num): diff --git a/src/borg/helpers/errors.py b/src/borg/helpers/errors.py index a5afb81bc..4560e7a7a 100644 --- a/src/borg/helpers/errors.py +++ b/src/borg/helpers/errors.py @@ -5,6 +5,9 @@ from ..constants import * # NOQA import borg.crypto.low_level +modern_ec = os.environ.get("BORG_EXIT_CODES", "legacy") == "modern" + + class Error(Exception): """Error: {}""" # Error base class @@ -29,9 +32,8 @@ class Error(Exception): @property def exit_code(self): # legacy: borg used to always use rc 2 (EXIT_ERROR) for all errors. - # modern: users can opt in to more specific return codes, using BORG_RC_STYLE: - modern = os.environ.get("BORG_EXIT_CODES", "legacy") == "modern" - return self.exit_mcode if modern else EXIT_ERROR + # modern: users can opt in to more specific return codes, using BORG_EXIT_CODES: + return self.exit_mcode if modern_ec else EXIT_ERROR class ErrorWithTraceback(Error): @@ -42,7 +44,23 @@ class ErrorWithTraceback(Error): class IntegrityError(ErrorWithTraceback, borg.crypto.low_level.IntegrityError): """Data integrity error: {}""" + exit_mcode = 90 class DecompressionError(IntegrityError): """Decompression error: {}""" + exit_mcode = 92 + + +class CancelledByUser(Error): + """Cancelled by user.""" + exit_mcode = 3 + + +class RTError(Error): + """Runtime Error: {}""" + + +class CommandError(Error): + """Command Error: {}""" + exit_mcode = 4 diff --git a/src/borg/helpers/manifest.py b/src/borg/helpers/manifest.py index 1de7e89f5..ea4494b44 100644 --- a/src/borg/helpers/manifest.py +++ b/src/borg/helpers/manifest.py @@ -18,12 +18,14 @@ from .. import shellpattern from ..constants import * # NOQA -class NoManifestError(Error): - """Repository has no manifest.""" - - class MandatoryFeatureUnsupported(Error): """Unsupported repository feature(s) {}. A newer version of borg is required to access this repository.""" + exit_mcode = 25 + + +class NoManifestError(Error): + """Repository has no manifest.""" + exit_mcode = 26 ArchiveInfo = namedtuple('ArchiveInfo', 'name id ts') diff --git a/src/borg/helpers/parseformat.py b/src/borg/helpers/parseformat.py index 3fdbe50d5..7fb60d71f 100644 --- a/src/borg/helpers/parseformat.py +++ b/src/borg/helpers/parseformat.py @@ -176,10 +176,12 @@ class DatetimeWrapper: class PlaceholderError(Error): """Formatting Error: "{}".format({}): {}({})""" + exit_mcode = 5 class InvalidPlaceholder(PlaceholderError): """Invalid placeholder "{}" in string: {}""" + exit_mcode = 6 def format_line(format, data): diff --git a/src/borg/locking.py b/src/borg/locking.py index 7ddb6df74..eda9692bb 100644 --- a/src/borg/locking.py +++ b/src/borg/locking.py @@ -69,26 +69,32 @@ class TimeoutTimer: class LockError(Error): """Failed to acquire the lock {}.""" + exit_mcode = 70 class LockErrorT(ErrorWithTraceback): """Failed to acquire the lock {}.""" - - -class LockTimeout(LockError): - """Failed to create/acquire the lock {} (timeout).""" + exit_mcode = 71 class LockFailed(LockErrorT): """Failed to create/acquire the lock {} ({}).""" + exit_mcode = 72 + + +class LockTimeout(LockError): + """Failed to create/acquire the lock {} (timeout).""" + exit_mcode = 73 class NotLocked(LockErrorT): """Failed to release the lock {} (was not locked).""" + exit_mcode = 74 class NotMyLock(LockErrorT): """Failed to release the lock {} (was/is locked, but not by me).""" + exit_mcode = 75 class ExclusiveLock: diff --git a/src/borg/remote.py b/src/borg/remote.py index 637f4d111..179f0ffdc 100644 --- a/src/borg/remote.py +++ b/src/borg/remote.py @@ -66,26 +66,32 @@ def os_write(fd, data): class ConnectionClosed(Error): """Connection closed by remote host""" + exit_mcode = 80 class ConnectionClosedWithHint(ConnectionClosed): """Connection closed by remote host. {}""" + exit_mcode = 81 class PathNotAllowed(Error): """Repository path not allowed: {}""" + exit_mcode = 83 class InvalidRPCMethod(Error): """RPC method {} is not valid""" + exit_mcode = 82 class UnexpectedRPCDataFormatFromClient(Error): """Borg {}: Got unexpected RPC data format from client.""" + exit_mcode = 85 class UnexpectedRPCDataFormatFromServer(Error): """Got unexpected RPC data format from server:\n{}""" + exit_mcode = 86 def __init__(self, data): try: @@ -517,6 +523,7 @@ class RemoteRepository: class RPCServerOutdated(Error): """Borg server is too old for {}. Required version {}""" + exit_mcode = 84 @property def method(self): diff --git a/src/borg/repository.py b/src/borg/repository.py index 1b285058e..8a45a5697 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -120,43 +120,54 @@ class Repository: will still get rid of them. """ - class DoesNotExist(Error): - """Repository {} does not exist.""" - class AlreadyExists(Error): """A repository already exists at {}.""" - - class PathAlreadyExists(Error): - """There is already something at {}.""" - - class ParentPathDoesNotExist(Error): - """The parent path of the repo directory [{}] does not exist.""" - - class InvalidRepository(Error): - """{} is not a valid repository. Check repo config.""" - - class InvalidRepositoryConfig(Error): - """{} does not have a valid configuration. Check repo config [{}].""" + exit_mcode = 10 class AtticRepository(Error): """Attic repository detected. Please run "borg upgrade {}".""" + exit_mcode = 11 class CheckNeeded(ErrorWithTraceback): """Inconsistency detected. Please run "borg check {}".""" + exit_mcode = 12 + + class DoesNotExist(Error): + """Repository {} does not exist.""" + exit_mcode = 13 + + class InsufficientFreeSpaceError(Error): + """Insufficient free space to complete transaction (required: {}, available: {}).""" + exit_mcode = 14 + + class InvalidRepository(Error): + """{} is not a valid repository. Check repo config.""" + exit_mcode = 15 + + class InvalidRepositoryConfig(Error): + """{} does not have a valid configuration. Check repo config [{}].""" + exit_mcode = 16 class ObjectNotFound(ErrorWithTraceback): """Object with key {} not found in repository {}.""" + exit_mcode = 17 def __init__(self, id, repo): if isinstance(id, bytes): id = bin_to_hex(id) super().__init__(id, repo) - class InsufficientFreeSpaceError(Error): - """Insufficient free space to complete transaction (required: {}, available: {}).""" + class ParentPathDoesNotExist(Error): + """The parent path of the repo directory [{}] does not exist.""" + exit_mcode = 18 + + class PathAlreadyExists(Error): + """There is already something at {}.""" + exit_mcode = 19 class StorageQuotaExceeded(Error): """The storage quota ({}) has been exceeded ({}). Try deleting some archives.""" + exit_mcode = 20 def __init__(self, path, create=False, exclusive=False, lock_wait=None, lock=True, append_only=False, storage_quota=None, check_segment_magic=True, diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index c1ae3c218..cc3851af2 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -40,7 +40,7 @@ from ..crypto.keymanager import RepoIdMismatch, NotABorgKeyFile from ..crypto.file_integrity import FileIntegrityError from ..helpers import Location, get_security_dir from ..helpers import Manifest, MandatoryFeatureUnsupported, ArchiveInfo -from ..helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR +from ..helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, Error, CancelledByUser, RTError, CommandError from ..helpers import bin_to_hex from ..helpers import MAX_S from ..helpers import msgpack @@ -1171,9 +1171,14 @@ class ArchiverTestCase(ArchiverTestCaseBase): def test_create_content_from_command_with_failed_command(self): self.cmd('init', '--encryption=repokey', self.repository_location) - output = self.cmd('create', '--content-from-command', self.repository_location + '::test', - '--', 'sh', '-c', 'exit 73;', exit_code=2) - assert output.endswith("Command 'sh' exited with status 73\n") + if self.FORK_DEFAULT: + output = self.cmd('create', '--content-from-command', self.repository_location + '::test', + '--', 'sh', '-c', 'exit 73;', exit_code=2) + assert output.endswith("Command 'sh' exited with status 73\n") + else: + with pytest.raises(CommandError): + self.cmd('create', '--content-from-command', self.repository_location + '::test', + '--', 'sh', '-c', 'exit 73;') archive_list = json.loads(self.cmd('list', '--json', self.repository_location)) assert archive_list['archives'] == [] @@ -1212,9 +1217,14 @@ class ArchiverTestCase(ArchiverTestCaseBase): def test_create_paths_from_command_with_failed_command(self): self.cmd('init', '--encryption=repokey', self.repository_location) - output = self.cmd('create', '--paths-from-command', self.repository_location + '::test', - '--', 'sh', '-c', 'exit 73;', exit_code=2) - assert output.endswith("Command 'sh' exited with status 73\n") + if self.FORK_DEFAULT: + output = self.cmd('create', '--paths-from-command', self.repository_location + '::test', + '--', 'sh', '-c', 'exit 73;', exit_code=2) + assert output.endswith("Command 'sh' exited with status 73\n") + else: + with pytest.raises(CommandError): + self.cmd('create', '--paths-from-command', self.repository_location + '::test', + '--', 'sh', '-c', 'exit 73;') archive_list = json.loads(self.cmd('list', '--json', self.repository_location)) assert archive_list['archives'] == [] @@ -1699,7 +1709,11 @@ class ArchiverTestCase(ArchiverTestCaseBase): self.cmd('create', self.repository_location + '::test', 'input') self.cmd('create', self.repository_location + '::test.2', 'input') os.environ['BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'] = 'no' - self.cmd('delete', self.repository_location, exit_code=2) + if self.FORK_DEFAULT: + self.cmd('delete', self.repository_location, exit_code=2) + else: + with pytest.raises(CancelledByUser): + self.cmd('delete', self.repository_location) assert os.path.exists(self.repository_path) os.environ['BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'] = 'YES' self.cmd('delete', self.repository_location) @@ -2470,8 +2484,16 @@ class ArchiverTestCase(ArchiverTestCaseBase): def test_list_json_args(self): self.cmd('init', '--encryption=repokey', self.repository_location) - self.cmd('list', '--json-lines', self.repository_location, exit_code=2) - self.cmd('list', '--json', self.repository_location + '::archive', exit_code=2) + if self.FORK_DEFAULT: + self.cmd('list', '--json-lines', self.repository_location, exit_code=2) + else: + with pytest.raises(CommandError): + self.cmd('list', '--json-lines', self.repository_location) + if self.FORK_DEFAULT: + self.cmd('list', '--json', self.repository_location + '::archive', exit_code=2) + else: + with pytest.raises(CommandError): + self.cmd('list', '--json', self.repository_location + '::archive') def test_log_json(self): self.create_test_files() @@ -3025,8 +3047,12 @@ class ArchiverTestCase(ArchiverTestCaseBase): def test_recreate_target_rc(self): self.cmd('init', '--encryption=repokey', self.repository_location) - output = self.cmd('recreate', self.repository_location, '--target=asdf', exit_code=2) - assert 'Need to specify single archive' in output + if self.FORK_DEFAULT: + output = self.cmd('recreate', self.repository_location, '--target=asdf', exit_code=2) + assert 'Need to specify single archive' in output + else: + with pytest.raises(CommandError): + self.cmd('recreate', self.repository_location, '--target=asdf') def test_recreate_target(self): self.create_test_files() @@ -3317,13 +3343,21 @@ class ArchiverTestCase(ArchiverTestCaseBase): self.cmd('init', self.repository_location, '--encryption', 'repokey') - self.cmd('key', 'export', self.repository_location, export_directory, exit_code=EXIT_ERROR) + if self.FORK_DEFAULT: + self.cmd('key', 'export', self.repository_location, export_directory, exit_code=EXIT_ERROR) + else: + with pytest.raises(CommandError): + self.cmd('key', 'export', self.repository_location, export_directory) def test_key_import_errors(self): export_file = self.output_path + '/exported' self.cmd('init', self.repository_location, '--encryption', 'keyfile') - self.cmd('key', 'import', self.repository_location, export_file, exit_code=EXIT_ERROR) + if self.FORK_DEFAULT: + self.cmd('key', 'import', self.repository_location, export_file, exit_code=EXIT_ERROR) + else: + with pytest.raises(CommandError): + self.cmd('key', 'import', self.repository_location, export_file) with open(export_file, 'w') as fd: fd.write('something not a key\n') @@ -3503,7 +3537,11 @@ id: 2 / e29442 3506da 4e1ea7 / 25f62a 5a3d41 - 02 self.cmd('config', self.repository_location, cfg_key, exit_code=1) self.cmd('config', '--list', '--delete', self.repository_location, exit_code=2) - self.cmd('config', self.repository_location, exit_code=2) + if self.FORK_DEFAULT: + self.cmd('config', self.repository_location, exit_code=2) + else: + with pytest.raises(CommandError): + self.cmd('config', self.repository_location) self.cmd('config', self.repository_location, 'invalid-option', exit_code=1) requires_gnutar = pytest.mark.skipif(not have_gnutar(), reason='GNU tar must be installed for this test.')