Sfoglia il codice sorgente

update "modern" error RCs (docs and code)

Thomas Waldmann 1 anno fa
parent
commit
9de07ebd46

+ 124 - 66
docs/internals/frontends.rst

@@ -565,92 +565,150 @@ 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
+    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.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.DoesNotExist
+    Archive.DoesNotExist rc: 31 traceback: no
         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.
-    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 installed properly
-    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 {}.
-    KeyfileMismatchError
+    KeyfileMismatchError rc: 41 traceback: no
         Mismatch between repository {} and key file {}.
-    KeyfileNotFoundError
+    KeyfileNotFoundError rc: 42 traceback: no
         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.
-    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.
+    UnsupportedKeyFormatError rc: 49 traceback:no
+        Your borg key is stored in an unsupported format. Try using a newer version of borg.
+
+
+    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: {}
+
 
 Operations
     - cache.begin_transaction

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

@@ -36,6 +36,9 @@ General:
         Main usecase for this is to automate fully ``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

+ 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
 =========== =======
 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)
 =========== =======
 

+ 8 - 2
src/borg/archive.py

@@ -454,15 +454,21 @@ def archive_put_items(chunk_ids, *, repo_objs, cache=None, stats=None, add_refer
 
 
 class Archive:
+    class AlreadyExists(Error):
+        """Archive {} already exists"""
+
+        exit_mcode = 30
+
     class DoesNotExist(Error):
         """Archive {} does not exist"""
 
-    class AlreadyExists(Error):
-        """Archive {} already exists"""
+        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,
         manifest,

+ 4 - 9
src/borg/archiver/__init__.py

@@ -25,7 +25,7 @@ try:
     from .. import __version__
     from ..constants import *  # NOQA
     from ..helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, EXIT_SIGNAL_BASE
-    from ..helpers import Error, set_ec
+    from ..helpers import Error, CommandError, set_ec, modern_ec
     from ..helpers import format_file_size
     from ..helpers import remove_surrogates, text_to_json
     from ..helpers import DatetimeWrapper, replace_placeholders
@@ -128,11 +128,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
@@ -631,7 +626,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()}"
@@ -687,9 +682,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))

+ 5 - 8
src/borg/archiver/check_cmd.py

@@ -2,7 +2,7 @@ import argparse
 from ._common import with_repository, Highlander
 from ..archive import ArchiveChecker
 from ..constants import *  # NOQA
-from ..helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR
+from ..helpers import EXIT_SUCCESS, EXIT_WARNING, CancelledByUser, CommandError
 from ..helpers import yes
 
 from ..logger import create_logger
@@ -30,22 +30,19 @@ class CheckMixIn:
                 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.match_archives)):
-            self.print_error(
+            raise CommandError(
                 "--repository-only contradicts --first, --last, -a / --match-archives and --verify-data arguments."
             )
-            return EXIT_ERROR
         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, max_duration=args.max_duration):
                 return EXIT_WARNING

+ 2 - 4
src/borg/archiver/config_cmd.py

@@ -7,7 +7,7 @@ from ._common import with_repository
 from ..cache import Cache, assert_secure
 from ..constants import *  # NOQA
 from ..helpers import EXIT_SUCCESS, EXIT_WARNING
-from ..helpers import Error
+from ..helpers import Error, CommandError
 from ..helpers import Location
 from ..helpers import parse_file_size
 from ..manifest import Manifest
@@ -99,9 +99,7 @@ class ConfigMixIn:
 
         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(".")
             except ValueError:

+ 7 - 11
src/borg/archiver/create_cmd.py

@@ -29,6 +29,7 @@ from ..helpers import prepare_subprocess_env
 from ..helpers import sig_int, ignore_sigint
 from ..helpers import iter_separated
 from ..helpers import MakePathSafeAction
+from ..helpers import Error, CommandError
 from ..manifest import Manifest
 from ..patterns import PatternMatcher
 from ..platform import is_win32
@@ -79,18 +80,15 @@ class CreateMixIn:
                                 preexec_fn=None if is_win32 else 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 = "+"  # included
                 self.print_file_status(status, path)
@@ -103,8 +101,7 @@ class CreateMixIn:
                             args.paths, stdout=subprocess.PIPE, env=env, preexec_fn=None if is_win32 else 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
@@ -135,8 +132,7 @@ class CreateMixIn:
                 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 == "":  # issue #5637
@@ -197,7 +193,7 @@ class CreateMixIn:
                 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

+ 8 - 12
src/borg/archiver/debug_cmd.py

@@ -13,6 +13,7 @@ from ..helpers import bin_to_hex, prepare_dump_dict
 from ..helpers import dash_open
 from ..helpers import StableDict
 from ..helpers import positive_int_validator, archivename_validator
+from ..helpers import CommandError, RTError
 from ..manifest import Manifest
 from ..platform import get_process_id
 from ..repository import Repository, LIST_SCAN_LIMIT, TAG_PUT, TAG_DELETE, TAG_COMMIT
@@ -191,8 +192,7 @@ class DebugMixIn:
         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
 
@@ -245,13 +245,11 @@ class DebugMixIn:
             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)
@@ -278,8 +276,7 @@ class DebugMixIn:
             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)}].")
 
         with open(args.object_path, "rb") as f:
             cdata = f.read()
@@ -306,8 +303,7 @@ class DebugMixIn:
             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)}].")
 
         with open(args.binary_path, "rb") as f:
             data = f.read()
@@ -334,8 +330,8 @@ class DebugMixIn:
             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)

+ 1 - 1
src/borg/archiver/delete_cmd.py

@@ -87,7 +87,7 @@ class DeleteMixIn:
                         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:

+ 7 - 13
src/borg/archiver/key_cmds.py

@@ -6,7 +6,7 @@ from ..constants import *  # NOQA
 from ..crypto.key import AESOCBRepoKey, CHPORepoKey, Blake2AESOCBRepoKey, Blake2CHPORepoKey
 from ..crypto.key import AESOCBKeyfileKey, CHPOKeyfileKey, Blake2AESOCBKeyfileKey, Blake2CHPOKeyfileKey
 from ..crypto.keymanager import KeyManager
-from ..helpers import PathSpec
+from ..helpers import PathSpec, CommandError
 from ..manifest import Manifest
 
 from ._common import with_repository
@@ -22,8 +22,7 @@ class KeysMixIn:
         """Change repository key file passphrase"""
         key = manifest.key
         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"):
@@ -36,8 +35,7 @@ class KeysMixIn:
         """Change repository key location"""
         key = manifest.key
         if not hasattr(key, "change_passphrase"):
-            print("This repository is not encrypted, cannot change the key location.")
-            return EXIT_ERROR
+            raise CommandError("This repository is not encrypted, cannot change the key location.")
 
         if args.key_mode == "keyfile":
             if isinstance(key, AESOCBRepoKey):
@@ -109,8 +107,7 @@ class KeysMixIn:
                 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)
@@ -119,16 +116,13 @@ class KeysMixIn:
         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
 

+ 5 - 8
src/borg/archiver/mount_cmds.py

@@ -3,7 +3,7 @@ import os
 
 from ._common import with_repository, Highlander
 from ..constants import *  # NOQA
-from ..helpers import EXIT_ERROR
+from ..helpers import RTError
 from ..helpers import PathSpec
 from ..helpers import umount
 from ..manifest import Manifest
@@ -22,16 +22,13 @@ class MountMixIn:
         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):
-            self.print_error(f"{args.mountpoint}: Mountpoint must be an **existing directory**")
-            return self.exit_code
+            raise RTError(f"{args.mountpoint}: Mountpoint must be an **existing directory**")
 
         if not os.access(args.mountpoint, os.R_OK | os.W_OK | os.X_OK):
-            self.print_error(f"{args.mountpoint}: Mountpoint must be a **writable** directory")
-            return self.exit_code
+            raise RTError(f"{args.mountpoint}: Mountpoint must be a **writable** directory")
 
         return self._do_mount(args)
 
@@ -46,7 +43,7 @@ class MountMixIn:
                 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):

+ 4 - 4
src/borg/archiver/prune_cmd.py

@@ -10,7 +10,7 @@ from ._common import with_repository, Highlander
 from ..archive import Archive, Statistics
 from ..cache import Cache
 from ..constants import *  # NOQA
-from ..helpers import ArchiveFormatter, interval, sig_int, log_multi, ProgressIndicatorPercent
+from ..helpers import ArchiveFormatter, interval, sig_int, log_multi, ProgressIndicatorPercent, CommandError, Error
 from ..manifest import Manifest
 
 from ..logger import create_logger
@@ -77,12 +77,12 @@ class PruneMixIn:
         if not any(
             (args.secondly, args.minutely, args.hourly, args.daily, args.weekly, args.monthly, args.yearly, args.within)
         ):
-            self.print_error(
+            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.'
             )
-            return self.exit_code
+
         if args.format is not None:
             format = args.format
         elif args.short:
@@ -173,7 +173,7 @@ class PruneMixIn:
             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:

+ 2 - 3
src/borg/archiver/rdelete_cmd.py

@@ -3,7 +3,7 @@ import argparse
 from ._common import with_repository
 from ..cache import Cache, SecurityManager
 from ..constants import *  # NOQA
-from ..helpers import EXIT_ERROR
+from ..helpers import CancelledByUser
 from ..helpers import format_archive
 from ..helpers import bin_to_hex
 from ..helpers import yes
@@ -72,8 +72,7 @@ class RDeleteMixIn:
                     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.")

+ 2 - 3
src/borg/archiver/recreate_cmd.py

@@ -5,7 +5,7 @@ from ._common import build_matcher
 from ..archive import ArchiveRecreater
 from ..constants import *  # NOQA
 from ..compress import CompressionSpec
-from ..helpers import archivename_validator, comment_validator, PathSpec, ChunkerParams
+from ..helpers import archivename_validator, comment_validator, PathSpec, ChunkerParams, CommandError
 from ..helpers import timestamp
 from ..manifest import Manifest
 
@@ -42,8 +42,7 @@ class RecreateMixIn:
 
         archive_names = tuple(archive.name for archive in manifest.archives.list_considering(args))
         if args.target is not None and len(archive_names) != 1:
-            self.print_error("--target: Need to specify single archive")
-            return self.exit_code
+            raise CommandError("--target: Need to specify single archive")
         for name in archive_names:
             if recreater.is_temporary_archive(name):
                 continue

+ 18 - 8
src/borg/cache.py

@@ -365,21 +365,31 @@ 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):
         path = cache_dir(repository, path)

+ 4 - 3
src/borg/constants.py

@@ -114,10 +114,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
 
 ISO_FORMAT_NO_USECS = "%Y-%m-%dT%H:%M:%S"

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

@@ -124,6 +124,8 @@ SUPPORTED_ALGORITHMS = {
 class FileIntegrityError(IntegrityError):
     """File failed integrity check: {}"""
 
+    exit_mcode = 91
+
 
 class IntegrityCheckedFile(FileLikeWrapper):
     def __init__(self, path, write, filename=None, override_fd=None, integrity_data=None):

+ 14 - 0
src/borg/crypto/key.py

@@ -38,30 +38,44 @@ AUTHENTICATED_NO_KEY = "authenticated_no_key" in workarounds
 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 UnsupportedKeyFormatError(Error):
     """Your borg key is stored in an unsupported format. Try using a newer version of borg."""
 
+    exit_mcode = 49
+
 
 def key_creator(repository, args, *, other_key=None):
     for key in AVAILABLE_KEY_TYPES:

+ 15 - 7
src/borg/crypto/keymanager.py

@@ -13,20 +13,28 @@ from ..repoobj import RepoObj
 from .key import CHPOKeyfileKey, RepoKeyNotFoundError, KeyBlobStorage, identify_key
 
 
-class UnencryptedRepo(Error):
-    """Keymanagement not available for unencrypted repositories."""
+class NotABorgKeyFile(Error):
+    """This file is not a borg key backup, aborting."""
 
-
-class UnknownKeyType(Error):
-    """Keytype {0} is unknown."""
+    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):

+ 2 - 1
src/borg/helpers/__init__.py

@@ -10,7 +10,8 @@ import os
 from ..constants import *  # NOQA
 from .checks import check_extension_modules, check_python
 from .datastruct import StableDict, Buffer, EfficientCollectionQueue
-from .errors import Error, ErrorWithTraceback, IntegrityError, DecompressionError
+from .errors import Error, ErrorWithTraceback, IntegrityError, DecompressionError, CancelledByUser, CommandError
+from .errors import RTError, modern_ec
 from .fs import ensure_dir, join_base_dir, get_socket_filename
 from .fs import get_security_dir, get_keys_dir, get_base_dir, get_cache_dir, get_config_dir, get_runtime_dir
 from .fs import dir_is_tagged, dir_is_cachedir, remove_dotdot_prefixes, make_path_safe, scandir_inorder

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

@@ -5,6 +5,9 @@ from ..constants import *  # NOQA
 from ..crypto.low_level import IntegrityError as IntegrityErrorBase
 
 
+modern_ec = os.environ.get("BORG_EXIT_CODES", "legacy") == "modern"
+
+
 class Error(Exception):
     """Error: {}"""
 
@@ -30,9 +33,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):
@@ -45,6 +47,26 @@ class ErrorWithTraceback(Error):
 class IntegrityError(ErrorWithTraceback, IntegrityErrorBase):
     """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

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

@@ -224,10 +224,14 @@ 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):
     for _, key, _, conversion in Formatter().parse(format):

+ 11 - 3
src/borg/helpers/passphrase.py

@@ -17,18 +17,26 @@ logger = create_logger()
 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 Passphrase(str):
     @classmethod

+ 15 - 3
src/borg/locking.py

@@ -71,26 +71,38 @@ 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:
     """An exclusive Lock based on mkdir fs operation being atomic.

+ 7 - 3
src/borg/manifest.py

@@ -18,12 +18,16 @@ from .patterns import get_regex_from_pattern
 from .repoobj import RepoObj
 
 
+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."""
 
-
-class MandatoryFeatureUnsupported(Error):
-    """Unsupported repository feature(s) {}. A newer version of borg is required to access this repository."""
+    exit_mcode = 26
 
 
 ArchiveInfo = namedtuple("ArchiveInfo", "name id ts")

+ 14 - 0
src/borg/remote.py

@@ -69,26 +69,38 @@ 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:
             data = data.decode()[:128]
@@ -513,6 +525,8 @@ class RemoteRepository:
     class RPCServerOutdated(Error):
         """Borg server is too old for {}. Required version {}"""
 
+        exit_mcode = 84
+
         @property
         def method(self):
             return self.args[0]

+ 30 - 10
src/borg/repository.py

@@ -134,41 +134,61 @@ class Repository:
     will still get rid of them.
     """
 
+    class AlreadyExists(Error):
+        """A repository already exists at {}."""
+
+        exit_mcode = 10
+
+    class CheckNeeded(ErrorWithTraceback):
+        """Inconsistency detected. Please run "borg check {}"."""
+
+        exit_mcode = 12
+
     class DoesNotExist(Error):
         """Repository {} does not exist."""
 
-    class AlreadyExists(Error):
-        """A repository already exists at {}."""
+        exit_mcode = 13
 
-    class PathAlreadyExists(Error):
-        """There is already something at {}."""
+    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 = 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 [{}]."""
 
-    class CheckNeeded(ErrorWithTraceback):
-        """Inconsistency detected. Please run "borg check {}"."""
+        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,

+ 7 - 1
src/borg/testsuite/archiver/config_cmd.py

@@ -1,7 +1,9 @@
 import os
+import pytest
 
 from ...constants import *  # NOQA
 from . import RK_ENCRYPTION, create_test_files, cmd, generate_archiver_tests
+from ...helpers import CommandError
 
 pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,binary")  # NOQA
 
@@ -40,5 +42,9 @@ def test_config(archivers, request):
         cmd(archiver, "config", cfg_key, exit_code=1)
 
     cmd(archiver, "config", "--list", "--delete", exit_code=2)
-    cmd(archiver, "config", exit_code=2)
+    if archiver.FORK_DEFAULT:
+        cmd(archiver, "config", exit_code=2)
+    else:
+        with pytest.raises(CommandError):
+            cmd(archiver, "config")
     cmd(archiver, "config", "invalid-option", exit_code=1)

+ 13 - 4
src/borg/testsuite/archiver/create_cmd.py

@@ -16,6 +16,7 @@ from ...constants import *  # NOQA
 from ...manifest import Manifest
 from ...platform import is_cygwin, is_win32, is_darwin
 from ...repository import Repository
+from ...helpers import CommandError
 from .. import has_lchflags
 from .. import changedir
 from .. import (
@@ -360,8 +361,12 @@ def test_create_content_from_command(archivers, request):
 def test_create_content_from_command_with_failed_command(archivers, request):
     archiver = request.getfixturevalue(archivers)
     cmd(archiver, "rcreate", RK_ENCRYPTION)
-    output = cmd(archiver, "create", "--content-from-command", "test", "--", "sh", "-c", "exit 73;", exit_code=2)
-    assert output.endswith("Command 'sh' exited with status 73" + os.linesep)
+    if archiver.FORK_DEFAULT:
+        output = cmd(archiver, "create", "--content-from-command", "test", "--", "sh", "-c", "exit 73;", exit_code=2)
+        assert output.endswith("Command 'sh' exited with status 73" + os.linesep)
+    else:
+        with pytest.raises(CommandError):
+            cmd(archiver, "create", "--content-from-command", "test", "--", "sh", "-c", "exit 73;")
     archive_list = json.loads(cmd(archiver, "rlist", "--json"))
     assert archive_list["archives"] == []
 
@@ -408,8 +413,12 @@ def test_create_paths_from_command(archivers, request):
 def test_create_paths_from_command_with_failed_command(archivers, request):
     archiver = request.getfixturevalue(archivers)
     cmd(archiver, "rcreate", RK_ENCRYPTION)
-    output = cmd(archiver, "create", "--paths-from-command", "test", "--", "sh", "-c", "exit 73;", exit_code=2)
-    assert output.endswith("Command 'sh' exited with status 73" + os.linesep)
+    if archiver.FORK_DEFAULT:
+        output = cmd(archiver, "create", "--paths-from-command", "test", "--", "sh", "-c", "exit 73;", exit_code=2)
+        assert output.endswith("Command 'sh' exited with status 73" + os.linesep)
+    else:
+        with pytest.raises(CommandError):
+            cmd(archiver, "create", "--paths-from-command", "test", "--", "sh", "-c", "exit 73;")
     archive_list = json.loads(cmd(archiver, "rlist", "--json"))
     assert archive_list["archives"] == []
 

+ 16 - 4
src/borg/testsuite/archiver/key_cmds.py

@@ -6,7 +6,7 @@ import pytest
 from ...constants import *  # NOQA
 from ...crypto.key import AESOCBRepoKey, AESOCBKeyfileKey, CHPOKeyfileKey, Passphrase
 from ...crypto.keymanager import RepoIdMismatch, NotABorgKeyFile
-from ...helpers import EXIT_ERROR
+from ...helpers import EXIT_ERROR, CommandError
 from ...helpers import bin_to_hex
 from ...helpers import msgpack
 from ...repository import Repository
@@ -170,7 +170,11 @@ def test_key_export_directory(archivers, request):
     export_directory = archiver.output_path + "/exported"
     os.mkdir(export_directory)
     cmd(archiver, "rcreate", RK_ENCRYPTION)
-    cmd(archiver, "key", "export", export_directory, exit_code=EXIT_ERROR)
+    if archiver.FORK_DEFAULT:
+        cmd(archiver, "key", "export", export_directory, exit_code=EXIT_ERROR)
+    else:
+        with pytest.raises(CommandError):
+            cmd(archiver, "key", "export", export_directory)
 
 
 def test_key_export_qr_directory(archivers, request):
@@ -178,14 +182,22 @@ def test_key_export_qr_directory(archivers, request):
     export_directory = archiver.output_path + "/exported"
     os.mkdir(export_directory)
     cmd(archiver, "rcreate", RK_ENCRYPTION)
-    cmd(archiver, "key", "export", "--qr-html", export_directory, exit_code=EXIT_ERROR)
+    if archiver.FORK_DEFAULT:
+        cmd(archiver, "key", "export", "--qr-html", export_directory, exit_code=EXIT_ERROR)
+    else:
+        with pytest.raises(CommandError):
+            cmd(archiver, "key", "export", "--qr-html", export_directory)
 
 
 def test_key_import_errors(archivers, request):
     archiver = request.getfixturevalue(archivers)
     export_file = archiver.output_path + "/exported"
     cmd(archiver, "rcreate", KF_ENCRYPTION)
-    cmd(archiver, "key", "import", export_file, exit_code=EXIT_ERROR)
+    if archiver.FORK_DEFAULT:
+        cmd(archiver, "key", "import", export_file, exit_code=EXIT_ERROR)
+    else:
+        with pytest.raises(CommandError):
+            cmd(archiver, "key", "import", export_file)
 
     with open(export_file, "w") as fd:
         fd.write("something not a key\n")

+ 8 - 1
src/borg/testsuite/archiver/rdelete_cmd.py

@@ -1,6 +1,9 @@
 import os
 
+import pytest
+
 from ...constants import *  # NOQA
+from ...helpers import CancelledByUser
 from . import create_regular_file, cmd, generate_archiver_tests, RK_ENCRYPTION
 
 pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,remote,binary")  # NOQA
@@ -14,7 +17,11 @@ def test_delete_repo(archivers, request):
     cmd(archiver, "create", "test", "input")
     cmd(archiver, "create", "test.2", "input")
     os.environ["BORG_DELETE_I_KNOW_WHAT_I_AM_DOING"] = "no"
-    cmd(archiver, "rdelete", exit_code=2)
+    if archiver.FORK_DEFAULT:
+        cmd(archiver, "rdelete", exit_code=2)
+    else:
+        with pytest.raises(CancelledByUser):
+            cmd(archiver, "rdelete")
     assert os.path.exists(archiver.repository_path)
     os.environ["BORG_DELETE_I_KNOW_WHAT_I_AM_DOING"] = "YES"
     cmd(archiver, "rdelete")

+ 7 - 2
src/borg/testsuite/archiver/recreate_cmd.py

@@ -5,6 +5,7 @@ from datetime import datetime
 import pytest
 
 from ...constants import *  # NOQA
+from ...helpers import CommandError
 from .. import changedir, are_hardlinks_supported
 from . import (
     _create_test_caches,
@@ -82,8 +83,12 @@ def test_recreate_hardlinked_tags(archivers, request):  # test for issue #4911
 def test_recreate_target_rc(archivers, request):
     archiver = request.getfixturevalue(archivers)
     cmd(archiver, "rcreate", RK_ENCRYPTION)
-    output = cmd(archiver, "recreate", "--target=asdf", exit_code=2)
-    assert "Need to specify single archive" in output
+    if archiver.FORK_DEFAULT:
+        output = cmd(archiver, "recreate", "--target=asdf", exit_code=2)
+        assert "Need to specify single archive" in output
+    else:
+        with pytest.raises(CommandError):
+            cmd(archiver, "recreate", "--target=asdf")
 
 
 def test_recreate_target(archivers, request):