Browse Source

update "modern" error RCs (docs and code)

Thomas Waldmann 1 year ago
parent
commit
62ad0369ef

+ 138 - 66
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
 full text, since texts change more frequently. Message IDs are unambiguous and reduce the need to parse
 log messages.
 log messages.
 
 
-Assigned message IDs are:
+Assigned message IDs and related error RCs (exit codes) are:
 
 
 .. See scripts/errorlist.py; this is slightly edited.
 .. See scripts/errorlist.py; this is slightly edited.
 
 
 Errors
 Errors
-    Archive.AlreadyExists
+    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 {}.
+    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({}): {}({})
+    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.
+
+    Archive.AlreadyExists rc: 30 traceback: no
         Archive {} already exists
         Archive {} already exists
-    Archive.DoesNotExist
+    Archive.DoesNotExist rc: 31 traceback: no
         Archive {} does not exist
         Archive {} does not exist
-    Archive.IncompatibleFilesystemEncodingError
+    Archive.IncompatibleFilesystemEncodingError rc: 32 traceback: no
         Failed to encode filename "{}" into file system encoding "{}". Consider configuring the LANG environment variable.
         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
-        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
-        Formatting Error: "{}".format({}): {}({})
-    KeyfileInvalidError
+
+    KeyfileInvalidError rc: 40 traceback: no
         Invalid key file for repository {} found in {}.
         Invalid key file for repository {} found in {}.
-    KeyfileMismatchError
+    KeyfileMismatchError rc: 41 traceback: no
         Mismatch between repository {} and key file {}.
         Mismatch between repository {} and key file {}.
-    KeyfileNotFoundError
+    KeyfileNotFoundError rc: 42 traceback: no
         No key file for repository {} found in {}.
         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
-        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
+    NotABorgKeyFile rc: 43 traceback: no
         This file is not a borg key backup, aborting.
         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.
         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 {}.
         Failed to acquire the lock {}.
-    LockErrorT
+    LockErrorT rc: 71 traceback: yes
         Failed to acquire the lock {}.
         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
         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
         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 {}
         Borg server is too old for {}. Required version {}
-    UnexpectedRPCDataFormatFromClient
+    UnexpectedRPCDataFormatFromClient rc: 85 traceback: no
         Borg {}: Got unexpected RPC data format from client.
         Borg {}: Got unexpected RPC data format from client.
-    UnexpectedRPCDataFormatFromServer
+    UnexpectedRPCDataFormatFromServer rc: 86 traceback: no
         Got unexpected RPC data format from server:
         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
 Operations
     - cache.begin_transaction
     - cache.begin_transaction

+ 3 - 0
docs/usage/general/environment.rst.inc

@@ -35,6 +35,9 @@ General:
         Main usecase for this is to fully automate ``borg change-passphrase``.
         Main usecase for this is to fully automate ``borg change-passphrase``.
     BORG_DISPLAY_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.
         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_HOST_ID
         Borg usually computes a host id from the FQDN plus the results of ``uuid.getnode()`` (which usually returns
         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
         a unique id based on the MAC address of the network interface. Except if that MAC happens to be all-zero - in

+ 4 - 2
docs/usage/general/return-codes.rst.inc

@@ -7,10 +7,12 @@ Borg can exit with the following return codes (rc):
 Return code Meaning
 Return code Meaning
 =========== =======
 =========== =======
 0           success (logged as INFO)
 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)
             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)
             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)
 128+N       killed by signal N (e.g. 137 == kill -9)
 =========== =======
 =========== =======
 
 

+ 6 - 3
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 Archive:
 
 
-    class DoesNotExist(Error):
-        """Archive {} does not exist"""
-
     class AlreadyExists(Error):
     class AlreadyExists(Error):
         """Archive {} already exists"""
         """Archive {} already exists"""
+        exit_mcode = 30
+
+    class DoesNotExist(Error):
+        """Archive {} does not exist"""
+        exit_mcode = 31
 
 
     class IncompatibleFilesystemEncodingError(Error):
     class IncompatibleFilesystemEncodingError(Error):
         """Failed to encode filename "{}" into file system encoding "{}". Consider configuring the LANG environment variable."""
         """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,
     def __init__(self, repository, key, manifest, name, cache=None, create=False,
                  checkpoint_interval=1800, numeric_ids=False, noatime=False, noctime=False,
                  checkpoint_interval=1800, numeric_ids=False, noatime=False, noctime=False,

+ 40 - 72
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.key import key_creator, key_argument_names, tam_required_file, tam_required, RepoKey, PassphraseKey
     from .crypto.keymanager import KeyManager
     from .crypto.keymanager import KeyManager
     from .helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, EXIT_SIGNAL_BASE
     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 positive_int_validator, location_validator, archivename_validator, ChunkerParams, Location
     from .helpers import PrefixSpec, GlobSpec, CommentSpec, PathSpec, SortBySpec, FilesCacheMode
     from .helpers import PrefixSpec, GlobSpec, CommentSpec, PathSpec, SortBySpec, FilesCacheMode
     from .helpers import BaseFormatter, ItemFormatter, ArchiveFormatter
     from .helpers import BaseFormatter, ItemFormatter, ArchiveFormatter
@@ -240,11 +240,6 @@ class Archiver:
         self.prog = prog
         self.prog = prog
         self.last_checkpoint = time.monotonic()
         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):
     def print_warning(self, msg, *args):
         msg = args and msg % args or msg
         msg = args and msg % args or msg
         self.exit_code = EXIT_WARNING  # we do not terminate here, so it is a warning
         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.",
             if not yes(msg, false_msg="Aborting.", invalid_msg="Invalid answer, aborting.",
                        truish=('YES', ), retry=False,
                        truish=('YES', ), retry=False,
                        env_var_override='BORG_CHECK_I_KNOW_WHAT_I_AM_DOING'):
                        env_var_override='BORG_CHECK_I_KNOW_WHAT_I_AM_DOING'):
-                return EXIT_ERROR
+                raise CancelledByUser()
         if args.repo_only and any(
         if args.repo_only and any(
            (args.verify_data, args.first, args.last, args.prefix is not None, args.glob_archives)):
            (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:
         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:
         if args.max_duration and not args.repo_only:
             # when doing a partial repo check, we can only check crc32 checksums in segment files,
             # 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.
             # 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.
             # 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.
             # 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 args.archives_only:
             if not repository.check(repair=args.repair, save_space=args.save_space, max_duration=args.max_duration):
             if not repository.check(repair=args.repair, save_space=args.save_space, max_duration=args.max_duration):
                 return EXIT_WARNING
                 return EXIT_WARNING
@@ -361,8 +353,7 @@ class Archiver:
     def do_change_passphrase(self, args, repository, manifest, key):
     def do_change_passphrase(self, args, repository, manifest, key):
         """Change repository key file passphrase"""
         """Change repository key file passphrase"""
         if not hasattr(key, 'change_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()
         key.change_passphrase()
         logger.info('Key updated')
         logger.info('Key updated')
         if hasattr(key, 'find_key'):
         if hasattr(key, 'find_key'):
@@ -384,8 +375,7 @@ class Archiver:
                 else:
                 else:
                     manager.export(args.path)
                     manager.export(args.path)
             except IsADirectoryError:
             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
         return EXIT_SUCCESS
 
 
     @with_repository(lock=False, exclusive=False, manifest=False, cache=False)
     @with_repository(lock=False, exclusive=False, manifest=False, cache=False)
@@ -394,16 +384,13 @@ class Archiver:
         manager = KeyManager(repository)
         manager = KeyManager(repository)
         if args.paper:
         if args.paper:
             if args.path:
             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)
             manager.import_paperkey(args)
         else:
         else:
             if not args.path:
             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):
             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)
             manager.import_keyfile(args)
         return EXIT_SUCCESS
         return EXIT_SUCCESS
 
 
@@ -536,16 +523,13 @@ class Archiver:
                             env = prepare_subprocess_env(system=True)
                             env = prepare_subprocess_env(system=True)
                             proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=ignore_sigint)
                             proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=ignore_sigint)
                         except (FileNotFoundError, PermissionError) as e:
                         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)
                         status = fso.process_pipe(path=path, cache=cache, fd=proc.stdout, mode=mode, user=user, group=group)
                         rc = proc.wait()
                         rc = proc.wait()
                         if rc != 0:
                         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:
                     except BackupOSError as e:
-                        self.print_error('%s: %s', path, e)
-                        return self.exit_code
+                        raise Error('%s: %s', path, e)
                 else:
                 else:
                     status = '-'
                     status = '-'
                 self.print_file_status(status, path)
                 self.print_file_status(status, path)
@@ -556,8 +540,7 @@ class Archiver:
                         env = prepare_subprocess_env(system=True)
                         env = prepare_subprocess_env(system=True)
                         proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=ignore_sigint)
                         proc = subprocess.Popen(args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=ignore_sigint)
                     except (FileNotFoundError, PermissionError) as e:
                     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
                     pipe_bin = proc.stdout
                 else:  # args.paths_from_stdin == True
                 else:  # args.paths_from_stdin == True
                     pipe_bin = sys.stdin.buffer
                     pipe_bin = sys.stdin.buffer
@@ -578,8 +561,7 @@ class Archiver:
                 if args.paths_from_command:
                 if args.paths_from_command:
                     rc = proc.wait()
                     rc = proc.wait()
                     if rc != 0:
                     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:
             else:
                 for path in args.paths:
                 for path in args.paths:
                     if path == '-':  # stdin
                     if path == '-':  # stdin
@@ -621,7 +603,7 @@ class Archiver:
                 if sig_int:
                 if sig_int:
                     # do not save the archive if the user ctrl-c-ed - it is valid, but incomplete.
                     # 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.
                     # we already have a checkpoint archive in this case.
-                    self.print_error("Got Ctrl-C / SIGINT.")
+                    raise Error("Got Ctrl-C / SIGINT.")
                 else:
                 else:
                     archive.save(comment=args.comment, timestamp=args.timestamp)
                     archive.save(comment=args.comment, timestamp=args.timestamp)
                     args.stats |= args.json
                     args.stats |= args.json
@@ -1189,8 +1171,7 @@ class Archiver:
         explicit_archives_specified = args.location.archive or args.archives
         explicit_archives_specified = args.location.archive or args.archives
         self.output_list = args.output_list
         self.output_list = args.output_list
         if archive_filter_specified and explicit_archives_specified:
         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:
         if archive_filter_specified or explicit_archives_specified:
             return self._delete_archives(args, repository)
             return self._delete_archives(args, repository)
         else:
         else:
@@ -1270,7 +1251,7 @@ class Archiver:
                         uncommitted_deletes = 0 if checkpointed else (uncommitted_deletes + 1)
                         uncommitted_deletes = 0 if checkpointed else (uncommitted_deletes + 1)
             if sig_int:
             if sig_int:
                 # Ctrl-C / SIGINT: do not checkpoint (commit) again, we already have a checkpoint in this case.
                 # 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:
             elif uncommitted_deletes > 0:
                 checkpoint_func()
                 checkpoint_func()
             if args.stats:
             if args.stats:
@@ -1325,8 +1306,7 @@ class Archiver:
                 msg = '\n'.join(msg)
                 msg = '\n'.join(msg)
                 if not yes(msg, false_msg="Aborting.", invalid_msg='Invalid answer, aborting.', truish=('YES',),
                 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'):
                            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:
             if not dry_run:
                 repository.destroy()
                 repository.destroy()
                 logger.info("Repository deleted.")
                 logger.info("Repository deleted.")
@@ -1348,12 +1328,10 @@ class Archiver:
 
 
         from .fuse_impl import llfuse, BORG_FUSE_IMPL
         from .fuse_impl import llfuse, BORG_FUSE_IMPL
         if llfuse is None:
         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):
         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)
         return self._do_mount(args)
 
 
@@ -1368,7 +1346,7 @@ class Archiver:
                 operations.mount(args.mountpoint, args.options, args.foreground)
                 operations.mount(args.mountpoint, args.options, args.foreground)
             except RuntimeError:
             except RuntimeError:
                 # Relevant error message already printed to stderr by FUSE
                 # Relevant error message already printed to stderr by FUSE
-                self.exit_code = EXIT_ERROR
+                raise RTError("FUSE mount failed")
         return self.exit_code
         return self.exit_code
 
 
     def do_umount(self, args):
     def do_umount(self, args):
@@ -1380,13 +1358,11 @@ class Archiver:
         """List archive or repository contents"""
         """List archive or repository contents"""
         if args.location.archive:
         if args.location.archive:
             if args.json:
             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)
             return self._list_archive(args, repository, manifest, key)
         else:
         else:
             if args.json_lines:
             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)
             return self._list_repository(args, repository, manifest, key)
 
 
     def _list_archive(self, 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"""
         """Prune repository archives according to specified rules"""
         if not any((args.secondly, args.minutely, args.hourly, args.daily,
         if not any((args.secondly, args.minutely, args.hourly, args.daily,
                     args.weekly, args.monthly, args.yearly, args.within)):
                     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:
         if args.prefix is not None:
             args.glob_archives = args.prefix + '*'
             args.glob_archives = args.prefix + '*'
         checkpoint_re = r'\.checkpoint(\.\d+)?'
         checkpoint_re = r'\.checkpoint(\.\d+)?'
@@ -1615,7 +1590,7 @@ class Archiver:
             pi.finish()
             pi.finish()
             if sig_int:
             if sig_int:
                 # Ctrl-C / SIGINT: do not checkpoint (commit) again, we already have a checkpoint in this case.
                 # 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:
             elif uncommitted_deletes > 0:
                 checkpoint_func()
                 checkpoint_func()
             if args.stats:
             if args.stats:
@@ -1722,15 +1697,13 @@ class Archiver:
         if args.location.archive:
         if args.location.archive:
             name = args.location.archive
             name = args.location.archive
             if recreater.is_temporary_archive(name):
             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):
             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:
         else:
             if args.target is not None:
             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']):
             for archive in manifest.archives.list(sort_by=['ts']):
                 name = archive.name
                 name = archive.name
                 if recreater.is_temporary_archive(name):
                 if recreater.is_temporary_archive(name):
@@ -1945,8 +1918,7 @@ class Archiver:
 
 
         if not args.list:
         if not args.list:
             if args.name is None:
             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:
             try:
                 section, name = args.name.split('.')
                 section, name = args.name.split('.')
@@ -2158,8 +2130,7 @@ class Archiver:
         except (ValueError, UnicodeEncodeError):
         except (ValueError, UnicodeEncodeError):
             wanted = None
             wanted = None
         if not wanted:
         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
         from .crypto.key import key_factory
         # set up the key without depending on a manifest obj
         # set up the key without depending on a manifest obj
@@ -2212,13 +2183,11 @@ class Archiver:
             if len(id) != 32:  # 256bit
             if len(id) != 32:  # 256bit
                 raise ValueError("id must be 256bits or 64 hex digits")
                 raise ValueError("id must be 256bits or 64 hex digits")
         except ValueError as err:
         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:
         try:
             data = repository.get(id)
             data = repository.get(id)
         except Repository.ObjectNotFound:
         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:
         with open(args.path, "wb") as f:
             f.write(data)
             f.write(data)
         print("object %s fetched." % hex_id)
         print("object %s fetched." % hex_id)
@@ -2244,8 +2213,7 @@ class Archiver:
             if len(id) != 32:  # 256bit
             if len(id) != 32:  # 256bit
                 raise ValueError("id must be 256bits or 64 hex digits")
                 raise ValueError("id must be 256bits or 64 hex digits")
         except ValueError as err:
         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)
         repository.put(id, data)
         print("object %s put." % hex_id)
         print("object %s put." % hex_id)
         repository.commit(compact=False)
         repository.commit(compact=False)
@@ -5330,7 +5298,7 @@ def main():  # pragma: no cover
         except argparse.ArgumentTypeError as e:
         except argparse.ArgumentTypeError as e:
             # we might not have logging setup yet, so get out quickly
             # we might not have logging setup yet, so get out quickly
             print(str(e), file=sys.stderr)
             print(str(e), file=sys.stderr)
-            sys.exit(EXIT_ERROR)
+            sys.exit(CommandError.exit_mcode if modern_ec else EXIT_ERROR)
         except Exception:
         except Exception:
             msg = 'Local Exception'
             msg = 'Local Exception'
             tb = f'{traceback.format_exc()}\n{sysinfo()}'
             tb = f'{traceback.format_exc()}\n{sysinfo()}'
@@ -5388,9 +5356,9 @@ def main():  # pragma: no cover
             exit_msg = 'terminating with %s status, rc %d'
             exit_msg = 'terminating with %s status, rc %d'
             if exit_code == EXIT_SUCCESS:
             if exit_code == EXIT_SUCCESS:
                 rc_logger.info(exit_msg % ('success', exit_code))
                 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))
                 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))
                 rc_logger.error(exit_msg % ('error', exit_code))
             elif exit_code >= EXIT_SIGNAL_BASE:
             elif exit_code >= EXIT_SIGNAL_BASE:
                 rc_logger.error(exit_msg % ('signal', exit_code))
                 rc_logger.error(exit_msg % ('signal', exit_code))

+ 13 - 8
src/borg/cache.py

@@ -341,20 +341,25 @@ class CacheConfig:
 class Cache:
 class Cache:
     """Client Side 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):
     class CacheInitAbortedError(Error):
         """Cache initialization aborted"""
         """Cache initialization aborted"""
+        exit_mcode = 60
+
+    class EncryptionMethodMismatch(Error):
+        """Repository encryption method changed since last access, refusing to continue"""
+        exit_mcode = 61
 
 
     class RepositoryAccessAborted(Error):
     class RepositoryAccessAborted(Error):
         """Repository access aborted"""
         """Repository access aborted"""
+        exit_mcode = 62
 
 
-    class EncryptionMethodMismatch(Error):
-        """Repository encryption method changed since last access, refusing to continue"""
+    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
     @staticmethod
     def break_lock(repository, path=None):
     def break_lock(repository, path=None):

+ 4 - 3
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)
 FILES_CACHE_MODE_DISABLED = 'd'  # most borg commands do not use the files cache at all (disable)
 
 
 # return codes returned by borg command
 # return codes returned by borg command
-# when borg is killed by signal N, rc = 128 + N
 EXIT_SUCCESS = 0  # everything done, no problems
 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
 EXIT_SIGNAL_BASE = 128  # terminated due to signal, rc = 128 + sig_no
 
 
 # never use datetime.isoformat(), it is evil. always use one of these:
 # never use datetime.isoformat(), it is evil. always use one of these:

+ 1 - 0
src/borg/crypto/file_integrity.py

@@ -119,6 +119,7 @@ SUPPORTED_ALGORITHMS = {
 
 
 class FileIntegrityError(IntegrityError):
 class FileIntegrityError(IntegrityError):
     """File failed integrity check: {}"""
     """File failed integrity check: {}"""
+    exit_mcode = 91
 
 
 
 
 class IntegrityCheckedFile(FileLikeWrapper):
 class IntegrityCheckedFile(FileLikeWrapper):

+ 19 - 4
src/borg/crypto/key.py

@@ -39,42 +39,52 @@ AUTHENTICATED_NO_KEY = 'authenticated_no_key' in workarounds
 
 
 class NoPassphraseFailure(Error):
 class NoPassphraseFailure(Error):
     """can not acquire a passphrase: {}"""
     """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):
 class PasscommandFailure(Error):
     """passcommand supplied in BORG_PASSCOMMAND failed: {}"""
     """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):
 class PasswordRetriesExceeded(Error):
     """exceeded the maximum password retries"""
     """exceeded the maximum password retries"""
+    exit_mcode = 53
 
 
 
 
 class UnsupportedPayloadError(Error):
 class UnsupportedPayloadError(Error):
     """Unsupported payload type {}. A newer version is required to access this repository."""
     """Unsupported payload type {}. A newer version is required to access this repository."""
+    exit_mcode = 48
 
 
 
 
 class UnsupportedManifestError(Error):
 class UnsupportedManifestError(Error):
     """Unsupported manifest envelope. A newer version is required to access this repository."""
     """Unsupported manifest envelope. A newer version is required to access this repository."""
+    exit_mcode = 27
 
 
 
 
 class KeyfileNotFoundError(Error):
 class KeyfileNotFoundError(Error):
     """No key file for repository {} found in {}."""
     """No key file for repository {} found in {}."""
+    exit_mcode = 42
 
 
 
 
 class KeyfileInvalidError(Error):
 class KeyfileInvalidError(Error):
     """Invalid key file for repository {} found in {}."""
     """Invalid key file for repository {} found in {}."""
+    exit_mcode = 40
 
 
 
 
 class KeyfileMismatchError(Error):
 class KeyfileMismatchError(Error):
     """Mismatch between repository {} and key file {}."""
     """Mismatch between repository {} and key file {}."""
+    exit_mcode = 41
 
 
 
 
 class RepoKeyNotFoundError(Error):
 class RepoKeyNotFoundError(Error):
     """No key entry found in the config of repository {}."""
     """No key entry found in the config of repository {}."""
+    exit_mcode = 44
 
 
 
 
 class TAMRequiredError(IntegrityError):
 class TAMRequiredError(IntegrityError):
@@ -87,6 +97,7 @@ class TAMRequiredError(IntegrityError):
     In the latter case, use "borg upgrade --tam --force '{}'" to re-authenticate the manifest.
     In the latter case, use "borg upgrade --tam --force '{}'" to re-authenticate the manifest.
     """).strip()
     """).strip()
     traceback = True
     traceback = True
+    exit_mcode = 98
 
 
 
 
 class ArchiveTAMRequiredError(TAMRequiredError):
 class ArchiveTAMRequiredError(TAMRequiredError):
@@ -94,11 +105,13 @@ class ArchiveTAMRequiredError(TAMRequiredError):
     Archive '{}' is unauthenticated, but it is required for this repository.
     Archive '{}' is unauthenticated, but it is required for this repository.
     """).strip()
     """).strip()
     traceback = True
     traceback = True
+    exit_mcode = 96
 
 
 
 
 class TAMInvalid(IntegrityError):
 class TAMInvalid(IntegrityError):
     __doc__ = IntegrityError.__doc__
     __doc__ = IntegrityError.__doc__
     traceback = True
     traceback = True
+    exit_mcode = 97
 
 
     def __init__(self):
     def __init__(self):
         # Error message becomes: "Data integrity error: Manifest authentication did not verify"
         # Error message becomes: "Data integrity error: Manifest authentication did not verify"
@@ -108,6 +121,7 @@ class TAMInvalid(IntegrityError):
 class ArchiveTAMInvalid(IntegrityError):
 class ArchiveTAMInvalid(IntegrityError):
     __doc__ = IntegrityError.__doc__
     __doc__ = IntegrityError.__doc__
     traceback = True
     traceback = True
+    exit_mcode = 95
 
 
     def __init__(self):
     def __init__(self):
         # Error message becomes: "Data integrity error: Archive authentication did not verify"
         # Error message becomes: "Data integrity error: Archive authentication did not verify"
@@ -117,6 +131,7 @@ class ArchiveTAMInvalid(IntegrityError):
 class TAMUnsupportedSuiteError(IntegrityError):
 class TAMUnsupportedSuiteError(IntegrityError):
     """Could not verify manifest: Unsupported suite {!r}; a newer version is needed."""
     """Could not verify manifest: Unsupported suite {!r}; a newer version is needed."""
     traceback = True
     traceback = True
+    exit_mcode = 99
 
 
 
 
 class KeyBlobStorage:
 class KeyBlobStorage:

+ 12 - 8
src/borg/crypto/keymanager.py

@@ -10,20 +10,24 @@ from ..repository import Repository
 from .key import KeyfileKey, KeyfileNotFoundError, RepoKeyNotFoundError, KeyBlobStorage, identify_key
 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):
 class RepoIdMismatch(Error):
     """This key backup seems to be for a different backup repository, aborting."""
     """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):
 def sha256_truncated(data, num):

+ 21 - 3
src/borg/helpers/errors.py

@@ -5,6 +5,9 @@ from ..constants import *  # NOQA
 import borg.crypto.low_level
 import borg.crypto.low_level
 
 
 
 
+modern_ec = os.environ.get("BORG_EXIT_CODES", "legacy") == "modern"
+
+
 class Error(Exception):
 class Error(Exception):
     """Error: {}"""
     """Error: {}"""
     # Error base class
     # Error base class
@@ -29,9 +32,8 @@ class Error(Exception):
     @property
     @property
     def exit_code(self):
     def exit_code(self):
         # legacy: borg used to always use rc 2 (EXIT_ERROR) for all errors.
         # 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):
 class ErrorWithTraceback(Error):
@@ -42,7 +44,23 @@ class ErrorWithTraceback(Error):
 
 
 class IntegrityError(ErrorWithTraceback, borg.crypto.low_level.IntegrityError):
 class IntegrityError(ErrorWithTraceback, borg.crypto.low_level.IntegrityError):
     """Data integrity error: {}"""
     """Data integrity error: {}"""
+    exit_mcode = 90
 
 
 
 
 class DecompressionError(IntegrityError):
 class DecompressionError(IntegrityError):
     """Decompression error: {}"""
     """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

+ 6 - 4
src/borg/helpers/manifest.py

@@ -18,12 +18,14 @@ from .. import shellpattern
 from ..constants import *  # NOQA
 from ..constants import *  # NOQA
 
 
 
 
-class NoManifestError(Error):
-    """Repository has no manifest."""
-
-
 class MandatoryFeatureUnsupported(Error):
 class MandatoryFeatureUnsupported(Error):
     """Unsupported repository feature(s) {}. A newer version of borg is required to access this repository."""
     """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')
 ArchiveInfo = namedtuple('ArchiveInfo', 'name id ts')

+ 2 - 0
src/borg/helpers/parseformat.py

@@ -176,10 +176,12 @@ class DatetimeWrapper:
 
 
 class PlaceholderError(Error):
 class PlaceholderError(Error):
     """Formatting Error: "{}".format({}): {}({})"""
     """Formatting Error: "{}".format({}): {}({})"""
+    exit_mcode = 5
 
 
 
 
 class InvalidPlaceholder(PlaceholderError):
 class InvalidPlaceholder(PlaceholderError):
     """Invalid placeholder "{}" in string: {}"""
     """Invalid placeholder "{}" in string: {}"""
+    exit_mcode = 6
 
 
 
 
 def format_line(format, data):
 def format_line(format, data):

+ 10 - 4
src/borg/locking.py

@@ -69,26 +69,32 @@ class TimeoutTimer:
 
 
 class LockError(Error):
 class LockError(Error):
     """Failed to acquire the lock {}."""
     """Failed to acquire the lock {}."""
+    exit_mcode = 70
 
 
 
 
 class LockErrorT(ErrorWithTraceback):
 class LockErrorT(ErrorWithTraceback):
     """Failed to acquire the lock {}."""
     """Failed to acquire the lock {}."""
-
-
-class LockTimeout(LockError):
-    """Failed to create/acquire the lock {} (timeout)."""
+    exit_mcode = 71
 
 
 
 
 class LockFailed(LockErrorT):
 class LockFailed(LockErrorT):
     """Failed to create/acquire the lock {} ({})."""
     """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):
 class NotLocked(LockErrorT):
     """Failed to release the lock {} (was not locked)."""
     """Failed to release the lock {} (was not locked)."""
+    exit_mcode = 74
 
 
 
 
 class NotMyLock(LockErrorT):
 class NotMyLock(LockErrorT):
     """Failed to release the lock {} (was/is locked, but not by me)."""
     """Failed to release the lock {} (was/is locked, but not by me)."""
+    exit_mcode = 75
 
 
 
 
 class ExclusiveLock:
 class ExclusiveLock:

+ 7 - 0
src/borg/remote.py

@@ -66,26 +66,32 @@ def os_write(fd, data):
 
 
 class ConnectionClosed(Error):
 class ConnectionClosed(Error):
     """Connection closed by remote host"""
     """Connection closed by remote host"""
+    exit_mcode = 80
 
 
 
 
 class ConnectionClosedWithHint(ConnectionClosed):
 class ConnectionClosedWithHint(ConnectionClosed):
     """Connection closed by remote host. {}"""
     """Connection closed by remote host. {}"""
+    exit_mcode = 81
 
 
 
 
 class PathNotAllowed(Error):
 class PathNotAllowed(Error):
     """Repository path not allowed: {}"""
     """Repository path not allowed: {}"""
+    exit_mcode = 83
 
 
 
 
 class InvalidRPCMethod(Error):
 class InvalidRPCMethod(Error):
     """RPC method {} is not valid"""
     """RPC method {} is not valid"""
+    exit_mcode = 82
 
 
 
 
 class UnexpectedRPCDataFormatFromClient(Error):
 class UnexpectedRPCDataFormatFromClient(Error):
     """Borg {}: Got unexpected RPC data format from client."""
     """Borg {}: Got unexpected RPC data format from client."""
+    exit_mcode = 85
 
 
 
 
 class UnexpectedRPCDataFormatFromServer(Error):
 class UnexpectedRPCDataFormatFromServer(Error):
     """Got unexpected RPC data format from server:\n{}"""
     """Got unexpected RPC data format from server:\n{}"""
+    exit_mcode = 86
 
 
     def __init__(self, data):
     def __init__(self, data):
         try:
         try:
@@ -517,6 +523,7 @@ class RemoteRepository:
 
 
     class RPCServerOutdated(Error):
     class RPCServerOutdated(Error):
         """Borg server is too old for {}. Required version {}"""
         """Borg server is too old for {}. Required version {}"""
+        exit_mcode = 84
 
 
         @property
         @property
         def method(self):
         def method(self):

+ 26 - 15
src/borg/repository.py

@@ -120,43 +120,54 @@ class Repository:
     will still get rid of them.
     will still get rid of them.
     """
     """
 
 
-    class DoesNotExist(Error):
-        """Repository {} does not exist."""
-
     class AlreadyExists(Error):
     class AlreadyExists(Error):
         """A repository already exists at {}."""
         """A repository already exists at {}."""
+        exit_mcode = 10
 
 
-    class PathAlreadyExists(Error):
-        """There is already something at {}."""
+    class AtticRepository(Error):
+        """Attic repository detected. Please run "borg upgrade {}"."""
+        exit_mcode = 11
 
 
-    class ParentPathDoesNotExist(Error):
-        """The parent path of the repo directory [{}] does not exist."""
+    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):
     class InvalidRepository(Error):
         """{} is not a valid repository. Check repo config."""
         """{} is not a valid repository. Check repo config."""
+        exit_mcode = 15
 
 
     class InvalidRepositoryConfig(Error):
     class InvalidRepositoryConfig(Error):
         """{} does not have a valid configuration. Check repo config [{}]."""
         """{} does not have a valid configuration. Check repo config [{}]."""
-
-    class AtticRepository(Error):
-        """Attic repository detected. Please run "borg upgrade {}"."""
-
-    class CheckNeeded(ErrorWithTraceback):
-        """Inconsistency detected. Please run "borg check {}"."""
+        exit_mcode = 16
 
 
     class ObjectNotFound(ErrorWithTraceback):
     class ObjectNotFound(ErrorWithTraceback):
         """Object with key {} not found in repository {}."""
         """Object with key {} not found in repository {}."""
+        exit_mcode = 17
 
 
         def __init__(self, id, repo):
         def __init__(self, id, repo):
             if isinstance(id, bytes):
             if isinstance(id, bytes):
                 id = bin_to_hex(id)
                 id = bin_to_hex(id)
             super().__init__(id, repo)
             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):
     class StorageQuotaExceeded(Error):
         """The storage quota ({}) has been exceeded ({}). Try deleting some archives."""
         """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,
     def __init__(self, path, create=False, exclusive=False, lock_wait=None, lock=True,
                  append_only=False, storage_quota=None, check_segment_magic=True,
                  append_only=False, storage_quota=None, check_segment_magic=True,

+ 53 - 15
src/borg/testsuite/archiver.py

@@ -40,7 +40,7 @@ from ..crypto.keymanager import RepoIdMismatch, NotABorgKeyFile
 from ..crypto.file_integrity import FileIntegrityError
 from ..crypto.file_integrity import FileIntegrityError
 from ..helpers import Location, get_security_dir
 from ..helpers import Location, get_security_dir
 from ..helpers import Manifest, MandatoryFeatureUnsupported, ArchiveInfo
 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 bin_to_hex
 from ..helpers import MAX_S
 from ..helpers import MAX_S
 from ..helpers import msgpack
 from ..helpers import msgpack
@@ -1171,9 +1171,14 @@ class ArchiverTestCase(ArchiverTestCaseBase):
 
 
     def test_create_content_from_command_with_failed_command(self):
     def test_create_content_from_command_with_failed_command(self):
         self.cmd('init', '--encryption=repokey', self.repository_location)
         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))
         archive_list = json.loads(self.cmd('list', '--json', self.repository_location))
         assert archive_list['archives'] == []
         assert archive_list['archives'] == []
 
 
@@ -1212,9 +1217,14 @@ class ArchiverTestCase(ArchiverTestCaseBase):
 
 
     def test_create_paths_from_command_with_failed_command(self):
     def test_create_paths_from_command_with_failed_command(self):
         self.cmd('init', '--encryption=repokey', self.repository_location)
         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))
         archive_list = json.loads(self.cmd('list', '--json', self.repository_location))
         assert archive_list['archives'] == []
         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', 'input')
         self.cmd('create', self.repository_location + '::test.2', 'input')
         self.cmd('create', self.repository_location + '::test.2', 'input')
         os.environ['BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'] = 'no'
         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)
         assert os.path.exists(self.repository_path)
         os.environ['BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'] = 'YES'
         os.environ['BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'] = 'YES'
         self.cmd('delete', self.repository_location)
         self.cmd('delete', self.repository_location)
@@ -2470,8 +2484,16 @@ class ArchiverTestCase(ArchiverTestCaseBase):
 
 
     def test_list_json_args(self):
     def test_list_json_args(self):
         self.cmd('init', '--encryption=repokey', self.repository_location)
         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):
     def test_log_json(self):
         self.create_test_files()
         self.create_test_files()
@@ -3025,8 +3047,12 @@ class ArchiverTestCase(ArchiverTestCaseBase):
 
 
     def test_recreate_target_rc(self):
     def test_recreate_target_rc(self):
         self.cmd('init', '--encryption=repokey', self.repository_location)
         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):
     def test_recreate_target(self):
         self.create_test_files()
         self.create_test_files()
@@ -3317,13 +3343,21 @@ class ArchiverTestCase(ArchiverTestCaseBase):
 
 
         self.cmd('init', self.repository_location, '--encryption', 'repokey')
         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):
     def test_key_import_errors(self):
         export_file = self.output_path + '/exported'
         export_file = self.output_path + '/exported'
         self.cmd('init', self.repository_location, '--encryption', 'keyfile')
         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:
         with open(export_file, 'w') as fd:
             fd.write('something not a key\n')
             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', self.repository_location, cfg_key, exit_code=1)
 
 
         self.cmd('config', '--list', '--delete', self.repository_location, exit_code=2)
         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)
         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.')
     requires_gnutar = pytest.mark.skipif(not have_gnutar(), reason='GNU tar must be installed for this test.')