repository: single config/config text object replaces readme, version, id and manifest (repo version 5)

Opening a repository loaded four store objects: config/readme (sanity check),
config/version, config/id and config/manifest (only read for its key type
byte since the manifest lost all other content). Now there is one plain text
INI object, config/config, e.g.:

    # This is a Borg Backup repository.
    # See https://borgbackup.readthedocs.io/

    [repository]
    version = 5
    id = <64 hex digits>
    encryption = aes256-ocb
    id_hash = sha256

The crypto suite of the key is recorded by the --encryption / --id-hash
names, so key_factory() selects the key class from the config without
reading any repository object. Both names are recorded or none; a config
with one but not the other is invalid, an unsupported suite raises
InvalidRepositoryConfig. The repository version is 5; only version 5 is
accepted, there is no code to read the old layout and no migration (betas
are for new repositories only).

The manifest object is gone for borg 2 repositories: Manifest is now only
the in-memory container for key, repo_objs, repository and archives; its
write() and all callers, get_manifest()/put_manifest(), the manifest checks
and rebuild in "borg check", "borg debug dump-manifest", the key type
detection from stored objects (key_from_repository) and the ROBJ_MANIFEST
type are removed. borg 1.x repositories are still read via their manifest
(legacy_key_factory), as it holds their archives list. NoManifestError is
kept (never raised) so that rc 26 stays reserved.

Repository.create() creates all namespace directories in advance
(store.create_levels()) and writes the config (version, id, no key info)
right away, so a repository created via the Python API is an openable
key/value store. "borg repo-create" alone defers the config write
(create_config=False) until after the key was created, so nothing that
looks like a repository exists until repo-create succeeds: any failure or
interruption before that destroys the store (and the keyfile, in keyfile
mode), and a failure inside create() destroys the store as well.

A store without repository config is reported as not a valid repository
(InvalidRepository; DoesNotExist is only for a missing store backend, the
rest:// case from #10365). repo-create on it raises IncompleteRepository
(rc 11, "has no repository config"), as the store backend refuses any
non-empty directory. "borg repo-delete --force" destroys such a store, so
that ssh/rest/s3 users can remove it without other access to the storage,
but only if it has the packs, archives, index and config namespaces every
store borg created has: a data directory, a home directory or a borg 1.x
repository is never destroyed by borg. key_factory() on a config without
key info raises RepositoryKeyInfoMissing (rc 54).

The cache config's [cache]/[integrity] cross-check (manifest id) could
never detect anything and is dropped.

Docs: new "Repository config" section replaces the manifest section
(including how to recreate a lost config by hand, as check --repair can
not), the layout list, packs and security internals (where the protection
against a swapped crypto suite lives), repo-info example, error list and the
remaining manifest mentions are updated.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Thomas Waldmann
2026-09-18 00:28:33 +02:00
co-authored by Claude Fable 5.1
parent e23aaac364
commit 1110bcddba
40 changed files with 712 additions and 667 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ Can I back up from multiple servers into a single repository?
Yes, you can! Even simultaneously.
The clocks of machines sharing a repository should be roughly synchronized
(e.g. via NTP): repository locks and archive/manifest timestamps are based on
(e.g. via NTP): repository locks and archive timestamps are based on
the clients' clocks, so big clock differences between clients can cause
trouble. Where the storage backend provides object timestamps (file, sftp, s3
and current rest servers - but not rclone), borg cross-checks lock staleness
+2 -2
View File
@@ -9,8 +9,8 @@ of Borg.
Borg uses a low-level, key-value store, the :ref:`repository`, and
implements a more complex data structure on top of it, which is made
up of the :ref:`manifest <manifest>`, :ref:`archives <archive>`,
:ref:`items <item>` and data :ref:`chunks`.
up of the :ref:`archives <archive>`, :ref:`items <item>` and data
:ref:`chunks`.
Each repository can hold multiple :ref:`archives <archive>`, which
represent individual backups that contain a full archive of the files
+55 -64
View File
@@ -38,14 +38,8 @@ It is the same for every repository and independent of the key/encryption mode
(unlike the chunk id hash, which the key mode selects).
config/
readme
simple text object telling that this is a Borg repository
id
the unique repository ID encoded as hexadecimal number text
version
the repository version encoded as decimal number text
manifest
the manifest (see :ref:`manifest`), a repository object, binary
config
the repository config (see :ref:`repo_config`), a text object
space-reserve.N
purely random binary data to reserve space, e.g. for disk-full emergencies.
These objects are created and removed by ``borg repo-space``.
@@ -179,10 +173,9 @@ Repo object metadata
Metadata is a MessagePack-encoded (and encrypted/authenticated) dict with:
- type (the repo object type, a one-character string: ``M`` manifest,
``A`` archive metadata, ``C`` archive metadata stream chunk ids,
``S`` archive metadata stream chunk, ``F`` file content stream chunk -
see the ``ROBJ_*`` constants)
- type (the repo object type, a one-character string: ``A`` archive metadata,
``C`` archive metadata stream chunk ids, ``S`` archive metadata stream chunk,
``F`` file content stream chunk - see the ``ROBJ_*`` constants)
- ctype (compression type 0..255)
- clevel (compression level, one byte, interpreted depending on ctype - see
:ref:`data-compression`)
@@ -237,47 +230,59 @@ More on how this helps security in :ref:`security_structural_auth`.
:figwidth: 100%
:width: 100%
.. _manifest:
.. _repo_config:
The manifest
~~~~~~~~~~~~
Repository config
~~~~~~~~~~~~~~~~~
The manifest is a repository object stored as the ``config/manifest`` store
object (see Repository_), so it is not inside a pack file and not in the chunks
index. Different from all other repository objects, the chunk id in its object
header is not the hash of its content, but all-zero
(``Manifest.MANIFEST_ID``).
The repository config is the ``config/config`` store object (see Repository_), a
plain text ``INI``-style file. It is the only object borg needs to read to open a
repository. It looks like this::
The manifest is written when the repository is created and by ``borg check
--repair`` when it rebuilds a lost or corrupted manifest. Commands that modify
the repository also call ``Manifest.write()``, but that only stores a new
manifest object if the content changed. It looks like this:
# This is a Borg Backup repository.
# See https://borgbackup.readthedocs.io/
.. code-block:: python
[repository]
version = 5
id = 0a2744f216526be75ae14a5fa5b123127bb218558219f6203e09e4f220e45903
encryption = aes256-ocb
id_hash = sha256
{
'version': 2,
'archives': {},
'config': {},
}
*version* is the repository version. borg refuses to open a repository whose
version it does not support (currently, only version 5 is supported).
Borg 2 always writes *version* 2. Reading also accepts version 1, which is what
borg 1.x repositories have (they are supported read-only, e.g. for
``borg transfer``).
*id* is the unique repository ID (32 bytes, hex encoded). It does not change if
the repository is moved to another location. The keys of the repository are
bound to it (see :ref:`key_files`), and the client's cache and security
directories are named after it.
A *timestamp* entry, as written by borg 1.x and by older borg 2 versions, is
accepted and ignored when reading.
*encryption* and *id_hash* record the crypto suite of the repository's key, by
the same names ``borg repo-create --encryption`` and ``--id-hash`` accept. This
is how borg selects the key class when opening a repository, without reading any
repository object. The config is plaintext and not authenticated; see
:ref:`remote_access_security` for what protects against a swapped crypto suite.
Where the key is stored (keyfile or repokey) is not recorded
here: that is a property of each individual key, see :ref:`key_files`. Both
entries are present, or none: a repository created via the Python API
(``Repository.create()``) without a key has none, it can be used as a key/value
store, but borg refuses to load a key for it.
The *archives* dict is always empty: the list of archives is not part of the
manifest, each archive has its own pointer object in the ``archives/``
namespace, see :ref:`archive`.
``borg repo-create`` writes the config once, after the key was created: writing
it is what makes the store a repository. A store without it (e.g. the leftover of
an interrupted ``borg repo-create``) is not a repository: borg reports it as not
a valid repository, and ``borg repo-create`` refuses to create a repository in a
non-empty location, saying whether it found a repository config there.
``borg repo-delete --force`` destroys such a store, provided it looks like a borg
store (it has the packs, archives, index and config namespaces, which
``Repository.create()`` makes in advance), so that it can be removed without other
access to the storage.
*config* is a general-purpose location for additional metadata. All versions
of Borg preserve its contents. Currently, borg does not store anything in there.
A *config['item_keys']* list (written by older borg 2 versions) or a top-level
*item_keys* list (borg 1.x) is accepted and ignored when reading: *borg check*
does not validate item keys against such a list anymore, see Item_.
The config is the one object ``borg check --repair`` can not restore. If a
repository lost or damaged its config, it can be recreated by hand: the version
is 5, the id is in the key (the ``BORG_KEY <id>`` header line of a keyfile or of
a ``keys/`` object, see :ref:`key_files`), and the encryption mode and id hash
are what ``borg repo-create`` was given (the key type byte of any repository
object encodes them as well, see ``KeyType`` in ``constants.py``).
.. _archive:
@@ -1022,8 +1027,7 @@ version
currently always an integer, 2
repository_id
the repository ID, as stored in the repository's ``config/id`` object,
see Repository_.
the repository ID, as stored in the repository config (see :ref:`repo_config`).
crypt_key
the initial key material used for the AEAD crypto (512 bits)
@@ -1272,32 +1276,19 @@ the file's name (see :ref:`the files cache <cache>` about that name):
[cache]
version = 1
repository = 3c4...e59
manifest = 10e...21c
[integrity]
manifest = 10e...21c
files.9f8...a08 = {"algorithm": "SHA256", "digests": {"final": "e2a...b24"}}
The chunks index is not in this list: it is not a local file, but lives in the
repository below ``index/`` and has its own integrity mechanism, see
:ref:`pack-index-namespace`.
The manifest ID is duplicated in the integrity section due to the way all Borg
versions handle the config file. Instead of creating a "new" config file from
an internal representation containing only the data understood by Borg,
the config file is read in entirety (using the Python ConfigParser) and modified.
This preserves all sections and values not understood by the Borg version
modifying it.
Thus, if an older versions uses a cache with integrity data, it would preserve
the integrity section and its contents. If a integrity-aware Borg version
would read this cache, it would incorrectly report checksum errors, since
the older version did not update the checksums.
However, by duplicating the manifest ID in the integrity section, it is
easy to tell whether the checksums concern the current state of the cache.
If they do not match, borg logs a warning and just does not use the integrity
data.
The cache config file is read in its entirety (using the Python ConfigParser),
modified and written back, so sections and values a Borg version does not
understand are preserved. There is no guard against an older Borg version
updating the files cache without updating its integrity data: every Borg
version that can open a version 5 repository knows the ``[integrity]`` section.
A files cache that fails its integrity check (or can not be read at all) is
discarded, not used: borg then rebuilds the files cache from the most recent
+4
View File
@@ -831,6 +831,8 @@ Errors
Repository.AlreadyExists rc: 10 traceback: no
A repository already exists at {}.
Repository.IncompleteRepository rc: 11 traceback: no
{} has no repository config.
Repository.CheckNeeded rc: 12 traceback: yes
Inconsistency detected. Please run "borg check {}".
Repository.DoesNotExist rc: 13 traceback: no
@@ -900,6 +902,8 @@ Errors
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.
RepositoryKeyInfoMissing rc: 54 traceback: no
Repository {} has no key information in its config.
CacheInitAbortedError rc: 60 traceback: no
Cache initialization aborted
+7 -18
View File
@@ -125,7 +125,7 @@ rebuilt the index from it. Rewriting such a pack is repository-level repair, see
``OBJ_MAGIC`` occurs inside the payloads as well, so the scan accepts a candidate
only when it validates like any walked header. Validating needs the key, so a
repair that cannot read the manifest walks without it.
repair that cannot load the key walks without it.
In the ``none-*`` modes the tag is an unkeyed checksum, and in the
``authenticated-*`` modes it binds a blob to its chunk id and nothing else (see
@@ -357,22 +357,11 @@ without decrypting any blob and without the repository key.
Repository Version
------------------
Repositories using pack files require repository version **4**, and the version is the
only gate for the pack format.
Repositories using pack files require repository version **5** or later, and the version
is the only gate for the pack format.
``Repository.create()`` stores ``4`` as the ``config/version`` store object.
``Repository.save_config()`` stores the version in the repository config (see
:ref:`repo_config`; currently ``5``, which also introduced the config object itself).
``Repository.open()`` reads it back and, if it is not in
``Repository.acceptable_repo_versions`` (currently ``(4,)``), closes the store again
and raises ``InvalidRepositoryConfig`` -- before any repository data is read. A borg
version that only accepts version 3 rejects a version 4 repository the same way, so
the version bump alone locks out every client that does not know about packs.
Borg does have a feature flag mechanism for locking out clients more selectively
(``Manifest.check_repository_compatibility()``, fed from a ``feature_flags`` entry in
the manifest ``config`` -- see :ref:`manifest`), but it currently defines no flags at
all: ``Manifest.SUPPORTED_REPO_FEATURES`` is the empty set, and no borg code writes a
``feature_flags`` entry. On a repository borg creates, the compatibility check is
therefore a no-op; there is in particular no ``pack_files`` feature flag.
There is no migration path from version 3 repositories to version 4. Users of the
version 3 beta format must create a new repository with ``borg repo-create``.
``Repository.acceptable_repo_versions`` (currently ``(5,)``), closes the store again
and raises ``InvalidRepositoryConfig`` -- before any repository data is read.
+23 -5
View File
@@ -71,7 +71,7 @@ Above used to be all for borg 1.x and was the reason why it needed the
tertiary authentication mechanism (TAM) for manifest and archives.
borg 2 now stores the ro_type ("meaning") of a repo object's data into that
object's metadata (like e.g.: manifest vs. archive vs. user file content data).
object's metadata (like e.g.: archive metadata vs. user file content data).
When loading data from the repo, borg verifies that the type of object it got
matches the type it wanted. borg 2 does not use TAMs any more.
@@ -87,7 +87,7 @@ carry an unkeyed checksum rather than a MAC, and an attacker who modifies an
object can simply recompute it. What still constrains an attacker there is the
object ID being the (unkeyed) hash of the plaintext: the content of an existing
object can not be replaced without the ID no longer matching. But the object's
metadata, the archives list and the manifest are not anchored to anything secret,
metadata and the archives list are not anchored to anything secret,
so a ``none-*`` repository provides no tamper protection - only detection of
accidental corruption.
@@ -384,8 +384,26 @@ used:
repository objects, so the pointer object itself only reveals the archive id (a MAC
over the archive metadata) plus whatever the store records about it, e.g. its
modification time.
- ``config/manifest`` (an encrypted repository object), plus the plaintext
``config/version``, ``config/id`` and ``config/readme``.
- ``config/config`` -- the plaintext repository config: version, id and the names of
the crypto suite (encryption mode, id hash), see :ref:`repo_config`. It is neither
encrypted nor authenticated, so an attacker with repository access can rewrite the
crypto suite. What protects against a swapped crypto suite is not this object, but:
- a swap to a suite that does not encrypt (``none-*``, but also
``authenticated-*``: its key blob carries the same key material and no suite
name, so it loads fine) would make the client write plaintext. That is caught
by the client's security directory: it records the key type of every
repository the client accessed, and borg refuses to continue with
``EncryptionMethodMismatch`` if the suite changed. A repository that does not
encrypt and is unknown to the client is only accessed after an explicit
confirmation (``BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK``). Note that this
requires the client environment to be persistent, see `Attack model`_.
- a swap between encrypting suites can not expose plaintext: the client
would write new objects with the same secret key material under the other
cipher, and merely fail to read the existing objects. On a client that knows
the repository, the security directory catches this swap as well. The key blobs
themselves are bound to the repository id and unlocked by the passphrase, so a
client never ends up using key material of the attacker's choice.
- ``keys/<store hash>`` -- in ``repokey`` mode, the borg key(s), encrypted with the
passphrase-derived KEK (see :ref:`key_encryption`).
- ``locks/*`` and ``cache/*``. Note that the per-archive reference caches
@@ -492,7 +510,7 @@ Note that the msgpack unpackers of the RPC data channel (``get_limited_unpacker(
kinds ``client`` and ``server``) are deliberately configured with the maximum buffer
size, because whole repository objects are transferred through them. They therefore
do not bound the memory a peer can make the other side allocate; the stricter limits
of that helper apply to manifest, archive and key data.
of that helper apply to archive and key data.
The msgpack implementation used (msgpack-python) has a good security track record,
a large test suite and no issues found by fuzzing. It is based on the msgpack-c implementation,
-1
View File
@@ -12,7 +12,6 @@ what their name suggests: put objects into the repository / delete objects from
Please note:
- they will not update the chunks index about the object
- they will not update the manifest (so no automatic chunks index resync is triggered)
- they will not check whether the object is in use (e.g. before delete-obj)
- they will not update any metadata which may point to the object
+1 -1
View File
@@ -7,7 +7,7 @@ Examples
$ borg repo-info
Repository ID: 0a2744f216526be75ae14a5fa5b123127bb218558219f6203e09e4f220e45903
Location: /path/to/repo
Repository version: 4
Repository version: 5
Encrypted: Yes (repokey, aes256-ocb, sha256)
Security directory: /home/user/.local/share/borg/security/0a2744f216526be75ae14a5fa5b123127bb218558219f6203e09e4f220e45903
Cache: /home/user/.cache/borg/0a2744f216526be75ae14a5fa5b123127bb218558219f6203e09e4f220e45903
+8 -40
View File
@@ -24,7 +24,7 @@ from . import xattr
from .chunkers import get_chunker, Chunk, release_chunk_data
from .cache import ChunkListEntry, build_chunkindex_from_repo, write_chunkindex_to_repo
from .cache import write_chunkindex_invalid, delete_chunkindex_invalid
from .crypto.key import key_from_repository
from .crypto.key import key_factory
from .constants import * # NOQA
from .digests import ContentDigester
from .crypto.low_level import IntegrityError as IntegrityErrorBase
@@ -53,7 +53,7 @@ from .manifest import Manifest
from .patterns import PathPrefixPattern, FnmatchPattern, IECommand
from .item import Item, ArchiveItem, ItemDiff
from .platform import acl_get, acl_set, set_flags, get_flags, set_times, swidth
from .repository import Repository, NoManifestError
from .repository import Repository
from .repoobj import RepoObj, object_validator
# macOS: SF_DATALESS marks dataless placeholder files (e.g. cloud files not materialized locally).
@@ -841,7 +841,6 @@ Duration: {0.duration}
# fragment referencing uncommitted objects, which compact/rebuild prunes (#10239).
self.cache.write_chunks_index()
self.manifest.archives.create(name, self.id, metadata.time)
self.manifest.write()
return metadata
def calc_stats(self, cache, want_unique=True):
@@ -2266,9 +2265,8 @@ class ArchiveChecker:
# The rebuild validates every object header it walks, because a corrupt data_size parses fine
# and points the walk into the middle of the pack. That costs one metadata slot read and one
# decryption per object and it needs the key, so read the key here if we do not have it yet.
# manifest_only=True: self.chunks, the other key source of make_key, is set up below.
if repair and self.key is None:
self.key = self.make_key(repository, manifest_only=True)
self.key = self.make_key(repository)
if self.key is not None:
# the validator decrypts metadata slots, so it needs a RepoObj built from the key.
self.repo_objs = RepoObj(self.key)
@@ -2317,22 +2315,7 @@ class ArchiveChecker:
self.repo_objs.set_assert_id_place("repair")
if verify_data:
self.verify_data()
rebuild_manifest = False
try:
repository.get_manifest()
except NoManifestError:
logger.error("Repository manifest is missing.")
self.error_found = True
rebuild_manifest = True
else:
try:
self.manifest = Manifest.load(repository, key=self.key)
except IntegrityErrorBase as exc:
logger.error("Repository manifest is corrupted: %s", exc)
self.error_found = True
rebuild_manifest = True
if rebuild_manifest:
self.manifest = self.rebuild_manifest()
self.manifest = Manifest.load(repository, key=self.key)
# On Ctrl-C, skip any scan not yet started; a scan already running stops at its own boundary.
if find_lost_archives and not sig_int:
self.rebuild_archives_directory()
@@ -2347,7 +2330,7 @@ class ArchiveChecker:
newer=newer,
newest=newest,
)
# finish() writes the manifest and a consistent chunk index; run it on Ctrl-C too (#9850).
# finish() writes a consistent chunk index; run it on Ctrl-C too (#9850).
self.finish()
if sig_int:
if self.error_found:
@@ -2361,17 +2344,9 @@ class ArchiveChecker:
logger.info("Archive consistency check complete, no problems found.")
return self.repair or not self.error_found
def make_key(self, repository, manifest_only=False):
"""Return the key loaded by key_from_repository.
manifest_only: read only the manifest, else also the objects of the chunk ids in self.chunks.
"""
def chunk_ids(): # reads self.chunks only if the manifest does not identify the key type
for id, _ in self.chunks.iteritems():
yield id
return key_from_repository(repository, () if manifest_only else chunk_ids())
def make_key(self, repository):
"""Return the key of repository, see key_factory."""
return key_factory(repository)
def verify_data(self):
logger.info("Starting cryptographic data integrity verification...")
@@ -2458,12 +2433,6 @@ class ArchiveChecker:
errors,
)
def rebuild_manifest(self):
"""Rebuild the manifest object."""
logger.info("Rebuilding missing/corrupted manifest.")
return Manifest(self.key, self.repository)
def rebuild_archives_directory(self):
"""Rebuild the archives directory, undeleting archives.
@@ -2832,7 +2801,6 @@ class ArchiveChecker:
delete_chunkindex_invalid(self.repository)
# drop the in-memory index so close() does not persist it over the index just written.
self.repository.invalidate_chunk_index()
self.manifest.write()
class ArchiveRecreater:
+40 -9
View File
@@ -29,7 +29,9 @@ from ..logger import create_logger
logger = create_logger(__name__)
def get_repository(location, *, create, exclusive, lock_wait, lock, args, v1_legacy):
def get_repository(location, *, create, exclusive, lock_wait, lock, args, v1_legacy, allow_incomplete=False):
# create_config=False: when creating, the command (repo-create) writes the repository config itself,
# once the key exists, see Repository.create(). For an existing repository, the flag is irrelevant.
if location.proto == "ssh":
if v1_legacy:
from ..legacy.remote import LegacyRemoteRepository
@@ -47,20 +49,46 @@ def get_repository(location, *, create, exclusive, lock_wait, lock, args, v1_leg
elif (
location.proto in ("rest", "sftp", "file", "http", "https", "rclone", "s3", "b2") and not v1_legacy
): # stuff directly supported by borgstore
repository = Repository(location, create=create, exclusive=exclusive, lock_wait=lock_wait, lock=lock)
repository = Repository(
location,
create=create,
create_config=False,
allow_incomplete=allow_incomplete,
exclusive=exclusive,
lock_wait=lock_wait,
lock=lock,
)
else:
if v1_legacy:
from ..legacy.repository import LegacyRepository
RepoCls = LegacyRepository
repository = LegacyRepository(
location.path, create=create, exclusive=exclusive, lock_wait=lock_wait, lock=lock
)
else:
RepoCls = Repository
repository = RepoCls(location.path, create=create, exclusive=exclusive, lock_wait=lock_wait, lock=lock)
repository = Repository(
location.path,
create=create,
create_config=False,
allow_incomplete=allow_incomplete,
exclusive=exclusive,
lock_wait=lock_wait,
lock=lock,
)
return repository
def with_repository(create=False, lock=True, exclusive=False, manifest=True, cache=False, secure=True, allow_v1=False):
def with_repository(
create=False,
lock=True,
exclusive=False,
manifest=True,
cache=False,
secure=True,
allow_v1=False,
allow_incomplete=False,
):
"""
Method decorator for subcommand-handling methods: do_XYZ(self, args, repository, …)
@@ -72,6 +100,8 @@ def with_repository(create=False, lock=True, exclusive=False, manifest=True, cac
:param cache: open cache, pass it as keyword argument (implies manifest)
:param secure: do assert_secure after loading manifest
:param allow_v1: (bool) allow legacy Borg 1.x repositories
:param allow_incomplete: (bool) also open a store without repository config (repository.incomplete is
True then, nothing else is usable), see Repository.create() - for "borg repo-delete --force".
"""
# We may need to modify `lock` inside `wrapper`. Therefore we cannot use the
# `nonlocal` statement to access `lock` as modifications would also
@@ -100,11 +130,12 @@ def with_repository(create=False, lock=True, exclusive=False, manifest=True, cac
lock=lock,
args=args,
v1_legacy=v1_legacy,
allow_incomplete=allow_incomplete,
)
with repository:
acceptable_versions = (1,) if v1_legacy else (4,)
if repository.version not in acceptable_versions:
acceptable_versions = (1,) if v1_legacy else (5,)
if not getattr(repository, "incomplete", False) and repository.version not in acceptable_versions:
raise Error(
f"This borg version only accepts version {' or '.join(str(v) for v in acceptable_versions)} "
f"repos for -r/--repo, but not version {repository.version}. "
@@ -171,7 +202,7 @@ def with_other_repository(manifest=False, cache=False, required=False):
)
with repository:
acceptable_versions = (1,) if v1_legacy else (4,)
acceptable_versions = (1,) if v1_legacy else (5,)
if repository.version not in acceptable_versions:
raise Error(
f"This borg version only accepts version {' or '.join(str(v) for v in acceptable_versions)} "
+7 -10
View File
@@ -3,8 +3,8 @@ import os
from ._common import with_repository, Highlander
from ..archive import ArchiveChecker
from ..constants import * # NOQA
from ..crypto.key import key_from_repository
from ..helpers import set_ec, EXIT_WARNING, CancelledByUser, CommandError, Error, IntegrityError
from ..crypto.key import key_factory, RepositoryKeyInfoMissing
from ..helpers import set_ec, EXIT_WARNING, CancelledByUser, CommandError, Error
from ..helpers import relative_time_marker_validator, yes, ArchiveFormatter, sig_int
from ..helpers.argparsing import ArgumentParser
from ..helpers.time import archive_ts_now, calculate_relative_offset
@@ -69,12 +69,10 @@ class CheckMixIn:
# if we need the key later for the archives check, ask NOW for the passphrase! #1931
archive_checker = ArchiveChecker()
try:
archive_checker.key = archive_checker.make_key(repository, manifest_only=True)
except IntegrityError:
archive_checker.key = archive_checker.make_key(repository)
except RepositoryKeyInfoMissing:
if args.repair:
# repair needs the key to validate the index rebuild. The manifest did not give it,
# so read it from the objects the chunk index lists.
archive_checker.key = key_from_repository(repository)
raise # repair needs the key to validate the index rebuild
if args.format is not None:
format = args.format
else:
@@ -85,9 +83,8 @@ class CheckMixIn:
if not args.archives_only:
validate = None # the object validator for the index rebuild, which only a repair does
if args.repair:
# ids=(): read the key from the manifest only. Chunk objects are found through the index,
# which this check may find corrupt.
key = archive_checker.key if not args.repo_only else key_from_repository(repository, ())
# the key class comes from the repository config, so loading the key reads no object.
key = archive_checker.key if not args.repo_only else key_factory(repository)
validate = object_validator(RepoObj(key))
if not repository.check(
repair=args.repair,
-1
View File
@@ -15,7 +15,6 @@ class CopyMixIn:
"""Copy an archive to a new archive name."""
old_id = archive.id
archive.copy(args.newname)
manifest.write()
logger.info(f"id: {bin_to_hex(old_id):.8} -> {bin_to_hex(archive.id):.8}, name: {archive.name}.")
def build_parser_copy(self, subparsers, common_parser, mid_common_parser):
+9 -33
View File
@@ -3,7 +3,8 @@ import textwrap
from ..archive import Archive
from ..constants import * # NOQA
from ..crypto.key import key_from_repository, KeyfileInvalidError, RepoKeyNotFoundError, UnsupportedKeyFormatError
from ..crypto.key import key_factory, KeyfileInvalidError, RepoKeyNotFoundError, UnsupportedKeyFormatError
from ..crypto.key import RepositoryKeyInfoMissing
from ..helpers import msgpack
from ..helpers import FilesystemPathSpec
from ..helpers import sysinfo
@@ -11,7 +12,7 @@ from ..helpers import bin_to_hex, hex_to_bin, prepare_dump_dict
from ..helpers import dash_open
from ..helpers import StableDict
from ..helpers import archivename_validator, CompressionSpec
from ..helpers import CommandError, IntegrityError, RTError
from ..helpers import CommandError, RTError
from ..helpers.argparsing import ArgumentParser
from ..platform import get_process_id
from ..repository import Repository, LIST_SCAN_LIMIT, repo_lister
@@ -28,14 +29,14 @@ logger = create_logger()
def gap_validator(repository):
"""Return repoobj.object_validator for the key of repository, or None if there is no key to use.
The key is loaded with key_from_repository. There is no key to use if no stored object identifies
the key type (IntegrityError), no key is found (RepoKeyNotFoundError), or the key is invalid
The key is loaded with key_factory. There is no key to use if the repository config has no key info
(RepositoryKeyInfoMissing), no key is found (RepoKeyNotFoundError), or the key is invalid
(KeyfileInvalidError, UnsupportedKeyFormatError); a warning is logged then. Other errors, e.g. a
wrong passphrase, propagate.
"""
try:
key = key_from_repository(repository)
except (IntegrityError, RepoKeyNotFoundError, KeyfileInvalidError, UnsupportedKeyFormatError) as err:
key = key_factory(repository)
except (RepositoryKeyInfoMissing, RepoKeyNotFoundError, KeyfileInvalidError, UnsupportedKeyFormatError) as err:
logger.warning(f"Could not set up the key, so rewritten packs keep their superseded gap bytes: {err}")
return None
return object_validator(RepoObj(key))
@@ -116,18 +117,6 @@ class DebugMixIn:
with dash_open(args.path, "w") as fd:
output(fd)
@with_repository()
def do_debug_dump_manifest(self, args, repository, manifest):
"""Dumps decoded repository manifest."""
repo_objs = manifest.repo_objs
cdata = repository.get_manifest()
_, data = repo_objs.parse(manifest.MANIFEST_ID, cdata, ro_type=ROBJ_MANIFEST)
meta = prepare_dump_dict(msgpack.unpackb(data, object_hook=StableDict))
with dash_open(args.path, "w") as fd:
json.dump(meta, fd, indent=4)
@with_repository(manifest=False)
def do_debug_dump_repo_objs(self, args, repository):
"""Dumps (decrypted, decompressed) repository objects."""
@@ -142,7 +131,7 @@ class DebugMixIn:
with open(filename, "wb") as fd:
fd.write(data)
repo_objs = RepoObj(key_from_repository(repository))
repo_objs = RepoObj(key_factory(repository))
for id, stored_size in repo_lister(repository, limit=LIST_SCAN_LIMIT):
cdata = repository.get(id)
decrypt_dump(id, cdata)
@@ -175,7 +164,7 @@ class DebugMixIn:
if not wanted:
raise CommandError("search term needs to be hex:123abc or str:foobar style")
repo_objs = RepoObj(key_from_repository(repository))
repo_objs = RepoObj(key_factory(repository))
last_data = b""
last_id = None
@@ -380,19 +369,6 @@ class DebugMixIn:
subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name")
subparser.add_argument("path", metavar="PATH", type=FilesystemPathSpec, help="file to dump data into")
debug_dump_manifest_epilog = process_epilog(
"""
This command dumps manifest metadata of a repository in a decoded form to a file.
"""
)
subparser = ArgumentParser(
parents=[mid_common_parser],
description=self.do_debug_dump_manifest.__doc__,
epilog=debug_dump_manifest_epilog,
)
debug_parsers.add_subcommand("dump-manifest", subparser, help="dump decoded repository metadata (debug)")
subparser.add_argument("path", metavar="PATH", type=FilesystemPathSpec, help="file to dump data into")
debug_dump_repo_objs_epilog = process_epilog(
"""
This command dumps raw (but decrypted and decompressed) repo objects to files.
-1
View File
@@ -58,7 +58,6 @@ class DeleteMixIn:
if dry_run:
logger.info("Finished dry-run.")
elif deleted:
manifest.write()
self.print_warning('Done. Run "borg compact" to free space.', wc=None)
else:
self.print_warning("Aborted.", wc=None)
-1
View File
@@ -305,7 +305,6 @@ class PruneMixIn:
if args.json:
json_print(basic_json_data(manifest, extra={"archives": output_data}))
if num_archives_deleted > 0 and not args.dry_run:
manifest.write()
self.print_warning('Done. Run "borg compact" to free space.', wc=None)
if sig_int:
raise Error("Got Ctrl-C / SIGINT.")
-2
View File
@@ -51,8 +51,6 @@ class RecreateMixIn:
delete_original = True
if not recreater.recreate(archive_info.id, target, delete_original, args.comment):
logger.info(f"Skipped archive {name} {hex_id}: Nothing to do.")
if not args.dry_run:
manifest.write()
def build_parser_recreate(self, subparsers, common_parser, mid_common_parser):
from ._common import process_epilog
-1
View File
@@ -14,7 +14,6 @@ class RenameMixIn:
def do_rename(self, args, repository, manifest, cache, archive):
"""Rename an existing archive."""
archive.rename(args.newname)
manifest.write()
def build_parser_rename(self, subparsers, common_parser, mid_common_parser):
from ._common import process_epilog
+10 -4
View File
@@ -34,14 +34,20 @@ class RepoCreateMixIn:
logger.info('Initializing repository at "%s"' % path)
if other_key is not None:
other_key.copy_crypt_key = args.copy_crypt_key
key = None
try:
key = key_creator(repository, args, other_key=other_key)
except (EOFError, KeyboardInterrupt):
# writing the config is what makes the store a repository, see Repository.create().
repository.save_config(key)
except BaseException as exc:
# an interrupted or failed repo-create must not leave a (partial) repository behind, nor a keyfile.
if key is not None and key.storage == KeyBlobStorage.KEYFILE:
key.remove(key.target)
repository.destroy()
raise CancelledByUser()
if isinstance(exc, (EOFError, KeyboardInterrupt)):
raise CancelledByUser()
raise
manifest = Manifest(key, repository)
manifest.key = key
manifest.write()
with Cache(repository, manifest, warn_if_unencrypted=False):
pass
if key.has_secret_key: # any key-bearing suite (everything except the "none-*" modes)
+29 -5
View File
@@ -2,12 +2,13 @@ from ._common import with_repository
from ..cache import Cache
from ..security import SecurityManager
from ..constants import * # NOQA
from ..helpers import CancelledByUser
from ..helpers import CancelledByUser, Error
from ..helpers import format_archive
from ..helpers import bin_to_hex
from ..helpers import yes
from ..helpers.argparsing import ArgumentParser
from ..manifest import Manifest, NoManifestError
from ..crypto.key import RepositoryKeyInfoMissing
from ..manifest import Manifest
from ..logger import create_logger
@@ -15,13 +16,33 @@ logger = create_logger()
class RepoDeleteMixIn:
@with_repository(exclusive=True, manifest=False)
@with_repository(exclusive=True, manifest=False, allow_incomplete=True)
def do_repo_delete(self, args, repository):
"""Deletes a repository."""
self.output_list = args.output_list
dry_run = args.dry_run
keep_security_info = args.keep_security_info
if repository.incomplete:
# a store without repository config (see Repository.create()), e.g. the leftover of an
# interrupted repo-create: --force destroys it, if it looks like a store borg created.
location = repository._location.canonical_path()
if args.cache_only:
raise Error(f"{location} has no repository config, so its cache can not be identified.")
if not args.forced:
raise Error(f"{location} has no repository config. Deleting this requires the --force option.")
if not repository.looks_like_borg_store():
raise Error(
f"{location} has no repository config and does not look like a borg store "
"(no packs, archives, index and config namespaces), so borg does not destroy it."
)
if dry_run:
logger.info("Would destroy the store (it has no repository config).")
else:
repository.destroy()
logger.info("Store destroyed (it had no repository config).")
return
if not args.cache_only:
if not args.forced: # without --force, we let the user see the archives list and confirm.
id = bin_to_hex(repository.id)
@@ -34,7 +55,7 @@ class RepoDeleteMixIn:
f"You requested to DELETE the following repository completely "
f"*including* {n_archives} archives it contains:"
)
except NoManifestError:
except RepositoryKeyInfoMissing:
n_archives = None
msg.append(
"You requested to DELETE the following repository completely "
@@ -57,7 +78,7 @@ class RepoDeleteMixIn:
msg.append("This repository does not appear to have any archives.")
else:
msg.append(
"This repository seems to have no manifest, so we cannot "
"This repository has no key information in its config, so we cannot "
"tell anything about its contents."
)
@@ -99,6 +120,9 @@ class RepoDeleteMixIn:
with the ``--cache-only`` option, or keep the security info with the
``--keep-security-info`` option.
``--force`` also destroys a store that has no repository config, provided it looks
like a borg store (it has the packs, archives, index and config namespaces).
Always first use ``--dry-run --list`` to see what would be deleted.
"""
)
-1
View File
@@ -51,7 +51,6 @@ class UnDeleteMixIn:
if dry_run:
logger.info("Finished dry-run.")
elif undeleted:
manifest.write()
self.print_warning("Done.", wc=None)
else:
self.print_warning("Aborted.", wc=None)
+4 -18
View File
@@ -125,9 +125,7 @@ class CacheConfig:
config.add_section("cache")
config.set("cache", "version", "1")
config.set("cache", "repository", self.repository.id_str)
config.set("cache", "manifest", "")
config.add_section("integrity")
config.set("integrity", "manifest", "")
with SaveFile(self.config_path) as fd:
config.write(fd)
@@ -140,30 +138,18 @@ class CacheConfig:
self._config.read_file(fd)
self._check_upgrade(self.config_path)
self.id = self._config.get("cache", "repository")
self.manifest_id = hex_to_bin(self._config.get("cache", "manifest"))
try:
self.integrity = dict(self._config.items("integrity"))
if self._config.get("cache", "manifest") != self.integrity.pop("manifest"):
# The cache config file is updated (parsed with ConfigParser, the state of the ConfigParser
# is modified and then written out.), not re-created.
# Thus, older versions will leave our [integrity] section alone, making the section's data invalid.
# Therefore, we also add the manifest ID to this section and
# can discern whether an older version interfered by comparing the manifest IDs of this section
# and the main [cache] section.
self.integrity = {}
logger.warning("Cache integrity data not available: old Borg version modified the cache.")
except configparser.NoSectionError:
logger.debug("Cache integrity: No integrity data found (files, chunks). Cache is from old version.")
logger.debug("Cache integrity: no [integrity] section in the cache config, no integrity data.")
self.integrity = {}
def save(self, manifest=None):
if manifest:
self._config.set("cache", "manifest", manifest.id_str)
def save(self, with_integrity=False):
if with_integrity:
if not self._config.has_section("integrity"):
self._config.add_section("integrity")
for file, integrity_data in self.integrity.items():
self._config.set("integrity", file, integrity_data)
self._config.set("integrity", "manifest", manifest.id_str)
with SaveFile(self.config_path) as fd:
self._config.write(fd)
@@ -1360,7 +1346,7 @@ class AdHocWithFilesCache(FilesCacheMixin, ChunksMixin):
# of seeing a valid-looking but empty index (and so is_chunk_index_loaded reports False).
self.repository.invalidate_chunk_index()
pi.output("Saving cache config")
self.cache_config.save(self.manifest)
self.cache_config.save(with_integrity=True)
self.cache_config.close()
pi.finish()
self.cache_config = None
-1
View File
@@ -56,7 +56,6 @@ STDIN_MODE_DEFAULT = 0o660
READ_SPECIAL_TIMEOUT_DEFAULT = 1800.0
# RepoObj types
ROBJ_MANIFEST = "M" # Manifest (directory of archives, other metadata) object
ROBJ_ARCHIVE_META = "A" # main archive metadata object
ROBJ_ARCHIVE_CHUNKIDS = "C" # objects with a list of archive metadata stream chunkids
ROBJ_ARCHIVE_STREAM = "S" # archive metadata stream chunk (containing items)
+57 -68
View File
@@ -3,7 +3,6 @@ import hmac
import os
import textwrap
from hashlib import sha256
from itertools import islice
from math import ceil
from pathlib import Path
from typing import Any, Literal, ClassVar, Optional
@@ -25,9 +24,9 @@ from ..helpers.passphrase import Passphrase, PasswordRetriesExceeded, Passphrase
from ..helpers import msgpack
from ..helpers import workarounds
from ..item import Key, EncryptedKey
from ..manifest import Manifest, NoManifestError
from ..manifest import Manifest
from ..platform import SaveFile
from ..repoobj import RepoObj, RepoObj1
from ..repoobj import RepoObj1
from .low_level import bytes_to_int, num_cipher_blocks, hmac_sha256
@@ -240,6 +239,58 @@ def id_hash_argument_names():
return names
class RepositoryKeyInfoMissing(Error):
"""Repository {} has no key information in its config."""
exit_mcode = 54
def key_class_for(encryption, id_hash):
"""Return the key class of the crypto suite named by the repository config values encryption and id_hash.
None if no key class implements that suite (e.g. a newer borg version wrote the config).
"""
for key in AVAILABLE_KEY_TYPES:
if key.ENC_NAME == encryption and key.IDHASH_NAME == id_hash:
return key
return None
def key_class_of(repository):
"""Return the key class of repository, as recorded in its config (see Repository.save_config)."""
if repository.encryption is None or repository.id_hash is None:
raise RepositoryKeyInfoMissing(repository._location.canonical_path())
key_cls = key_class_for(repository.encryption, repository.id_hash)
if key_cls is None:
from ..repository import Repository # repository imports this module, hence the local import
raise Repository.InvalidRepositoryConfig(
repository._location.canonical_path(),
f'unsupported crypto suite: encryption "{repository.encryption}", id hash "{repository.id_hash}" '
"(a newer version of Borg may be required)",
)
return key_cls
def key_factory(repository, *, other=False):
"""Return the (loaded) key of repository, its class selected by the repository config."""
return key_class_of(repository).detect(repository, None, other=other)
def legacy_key_factory(repository, manifest_chunk, *, other=False):
"""Return the (loaded) key of a borg 1.x repository, its class selected by the key type byte of its manifest.
manifest_chunk: the stored manifest object of the borg 1.x repository. The first byte of its
crypted data is the key type byte (see identify_key).
"""
manifest_data = RepoObj1.extract_crypted_data(manifest_chunk)
assert manifest_data, "manifest data must not be zero bytes long"
key_cls = identify_key(manifest_data)
key = key_cls.detect(repository, manifest_data, other=other)
key.stored_type = manifest_data[0]
return key
def identify_key(manifest_data):
# the key-type byte only identifies the crypto suite (id hash, MAC, cipher), NOT where the key is
# stored: keyfile and repokey share one class now and accept both historic type bytes. The legacy
@@ -251,69 +302,6 @@ def identify_key(manifest_data):
raise UnsupportedPayloadError(key_type)
def identify_stored_key(manifest_chunk, *, ro_cls=RepoObj):
"""Return (key class, data slot) of the stored object manifest_chunk.
A stored object is an object header, a metadata slot and a data slot (see RepoObj). The first
byte of the data slot is the key type byte, which selects the key class.
manifest_chunk: the stored object, e.g. the manifest.
ro_cls: the RepoObj class that parses manifest_chunk.
Raises IntegrityError if manifest_chunk is damaged (see ro_cls.extract_crypted_data), and
UnsupportedPayloadError if the key type byte selects no key class usable with ro_cls.
"""
manifest_data = ro_cls.extract_crypted_data(manifest_chunk)
assert manifest_data, "manifest data must not be zero bytes long"
key_cls = identify_key(manifest_data)
if key_cls in LEGACY_KEY_TYPES and ro_cls is not RepoObj1:
# A borg 2 repository using a borg 1.x key type: that can only be a repository created by
# a borg 2 beta in the old "none" or "authenticated" mode, which have been replaced by the
# tagged envelope modes (see MACKeyBase). The legacy key classes only exist to read borg
# 1.x repositories (ro_cls is RepoObj1 then), e.g. for "borg transfer --from-borg1".
raise UnsupportedPayloadError(manifest_data[0])
return key_cls, manifest_data
def key_factory(repository, manifest_chunk, *, other=False, ro_cls=RepoObj):
key_cls, manifest_data = identify_stored_key(manifest_chunk, ro_cls=ro_cls)
key = key_cls.detect(repository, manifest_data, other=other)
key.stored_type = manifest_data[0]
return key
def key_from_repository(repository, ids=None):
"""Return the key of repository, loaded from the first stored object that identifies the key type.
Stored objects are read in this order: the manifest, then the objects of the chunk ids in ids, at
most 999 of them. An object identifies the key type if identify_stored_key accepts it. Errors
loading the key, e.g. a wrong passphrase, propagate.
repository: the Repository whose key is loaded.
ids: iterable of chunk ids. None: the chunk ids in the chunks index of repository.
Raises IntegrityError if no object read identifies the key type.
"""
max_objects = 999
def stored_objects():
try:
yield repository.get_manifest()
except NoManifestError:
pass
chunk_ids = (id for id, _ in repository.list(limit=max_objects)) if ids is None else ids
for id in islice(chunk_ids, max_objects):
yield repository.get(id)
count = 0
for cdata in stored_objects():
count += 1
try:
identify_stored_key(cdata)
except (IntegrityError, UnsupportedPayloadError):
continue
return key_factory(repository, cdata)
raise IntegrityError(f"no stored object identifies the key type ({count} objects read)")
def uses_same_chunker_secret(other_key, key):
"""is the chunker secret the same?"""
# avoid breaking the deduplication by a different chunker secret
@@ -1561,8 +1549,9 @@ class AEADKeyBase(KeyBase):
# Each of these is one unified key class per crypto suite. A key of this class may be stored either as
# a keyfile or inside the repository (repokey) - that is a per-key storage property (self.storage), not
# a class distinction. The class is selected from the manifest's key-type byte (see identify_key), which
# only encodes the crypto suite (there is exactly one type byte per suite now).
# a class distinction. The class is selected from the crypto suite recorded in the repository config (see
# key_class_for); the key-type byte in every stored object only encodes the crypto suite as well (there is
# exactly one type byte per suite now).
# AES-OCB has a birthday-type bound: an attacker's advantage in distinguishing the ciphertexts from
# random is about 6 * sigma^2 / 2^128, sigma being the number of 128bit cipher blocks encrypted using
+2 -5
View File
@@ -5,11 +5,10 @@ import textwrap
from hashlib import sha256
from ..helpers import Error, CommandError, yes, bin_to_hex, hex_to_bin, dash_open, get_keys_dir
from ..repoobj import RepoObj
from .key import keyfile_format, keyfile_parse, is_keyfile
from .key import RepoKeyNotFoundError, KeyBlobStorage, KEY_LOCATIONS, identify_key, keyfile_name_for
from .key import RepoKeyNotFoundError, KeyBlobStorage, KEY_LOCATIONS, key_class_of, keyfile_name_for
class NotABorgKeyFile(Error):
@@ -51,9 +50,7 @@ class KeyManager:
self.loaded_key_id = None
self.loaded_label = None
manifest_chunk = repository.get_manifest()
manifest_data = RepoObj.extract_crypted_data(manifest_chunk)
self.key_cls = identify_key(manifest_data)
self.key_cls = key_class_of(repository)
self.keyblob_storage = self.key_cls.STORAGE
if self.keyblob_storage == KeyBlobStorage.NO_STORAGE:
raise UnencryptedRepo()
-4
View File
@@ -12,7 +12,6 @@ from datetime import datetime
from operator import attrgetter
from ..constants import * # NOQA
from ..helpers.datastruct import StableDict
from ..helpers.errors import CommandError, Error
from ..helpers.parseformat import bin_to_hex
from ..helpers.time import parse_timestamp, compile_date_pattern, DatePatternError
@@ -46,9 +45,6 @@ class LegacyArchives:
def prepare(self, manifest, m):
self._set_raw_dict(m.archives)
def finish(self, manifest):
return StableDict(self._get_raw_dict())
def ids(self, *, deleted=False):
for archive_info in self._archives.values():
yield archive_info["id"]
+34 -46
View File
@@ -12,7 +12,6 @@ from .logger import create_logger
logger = create_logger()
from .constants import * # NOQA
from .helpers.datastruct import StableDict
from .helpers.parseformat import bin_to_hex, hex_to_bin
from .helpers.time import (
parse_timestamp,
@@ -36,6 +35,8 @@ class MandatoryFeatureUnsupported(Error):
exit_mcode = 25
# Not raised anymore: borg 2 repositories have no manifest object (their config is the config/config store
# object). The class is kept so that its return code stays reserved and never gets a different meaning.
class NoManifestError(Error):
"""Repository has no manifest."""
@@ -129,7 +130,6 @@ class ArchivesInterface(Protocol): # pragma: no cover
"""
def prepare(self, manifest, m) -> None: ...
def finish(self, manifest) -> dict: ...
def ids(self, *, deleted: bool = False) -> Iterator: ...
def count(self) -> int: ...
def names(self) -> Iterator: ...
@@ -174,10 +174,7 @@ class Archives:
self.manifest = manifest
def prepare(self, manifest, m):
pass # borgstore manages the archive directory; nothing to load from the manifest blob
def finish(self, manifest):
return {} # manifest["archives"] is always empty in Borg 2
pass # only the legacy borg 1.x manifest has an archives list to load, see LegacyArchives
def ids(self, *, deleted=False):
# yield the binary IDs of all archives
@@ -483,7 +480,18 @@ class Archives:
class Manifest:
MANIFEST_ID = b"\0" * 32
"""
The repository's key, RepoObj and archives directory, bundled for the code that works with archives.
Historically (borg 1.x), the manifest was a repository object holding the archives list and other
metadata. borg 2 repositories have no manifest object: the archives are in the archives/ namespace
and the repository config is the config/config store object (see Repository.save_config). This class
only lives on as the container the archive-level code takes its key, repo_objs, repository and
archives from. For borg 1.x repositories (read-only, e.g. "borg transfer --from-borg1"), load()
still reads the manifest object, as it holds their archives list.
"""
MANIFEST_ID = b"\0" * 32 # legacy: the id of a borg 1.x repository's manifest object
def __init__(self, key, repository, ro_cls=RepoObj):
from .legacy.repository import LegacyRepository
@@ -494,57 +502,37 @@ class Manifest:
self.archives: ArchivesInterface = LegacyArchives(repository, self)
else:
self.archives: ArchivesInterface = Archives(repository, self)
self.config = {}
self.key = key
self.repo_objs = ro_cls(key)
self.repository = repository
self._loaded_data = None # the packed manifest as loaded from the repository, see write()
@property
def id_str(self):
return bin_to_hex(self.id)
@classmethod
def load(cls, repository, key=None, *, other=False, ro_cls=RepoObj):
"""Return the Manifest of repository, loading its key (see key_factory) if key is not given."""
from .crypto.key import key_factory # crypto.key imports this module, hence the local import
from .legacy.repository import LegacyRepository
from .legacy.remote import LegacyRemoteRepository
if isinstance(repository, (LegacyRepository, LegacyRemoteRepository)):
return cls._load_legacy(repository, key, other=other, ro_cls=ro_cls)
if not key:
key = key_factory(repository, other=other)
return cls(key, repository, ro_cls=ro_cls)
@classmethod
def _load_legacy(cls, repository, key, *, other, ro_cls):
# a borg 1.x repository: its manifest object identifies the key type and holds the archives list.
from .item import ManifestItem
from .crypto.key import key_factory
from .crypto.key import legacy_key_factory
cdata = repository.get_manifest()
if not key:
key = key_factory(repository, cdata, other=other, ro_cls=ro_cls)
key = legacy_key_factory(repository, cdata, other=other)
manifest = cls(key, repository, ro_cls=ro_cls)
_, data = manifest.repo_objs.parse(cls.MANIFEST_ID, cdata, ro_type=ROBJ_MANIFEST)
manifest._loaded_data = data
manifest_dict = key.unpack_manifest(data)
m = ManifestItem(internal_dict=manifest_dict)
manifest.id = manifest.repo_objs.id_hash(data)
# borg 1.x objects carry no type in their (non-existent) metadata; RepoObj1.parse ignores ro_type.
_, data = manifest.repo_objs.parse(cls.MANIFEST_ID, cdata, ro_type=ROBJ_DONTCARE)
m = ManifestItem(internal_dict=key.unpack_manifest(data))
if m.get("version") not in (1, 2):
raise ValueError("Invalid manifest version")
manifest.archives.prepare(manifest, m)
# a "timestamp" entry (written by borg 1.x and by older borg 2 versions) is ignored, as is
# the list of item keys (borg 1.x: "item_keys", older borg 2 versions: config["item_keys"]).
manifest.config = m.config
manifest.config.pop("item_keys", None)
return manifest
def write(self):
"""
Store the manifest in the repository, but only if its content differs from what was loaded.
The manifest content is static (borg does not store anything in its config dict currently), so
archive operations calling this usually do not result in a store write: only when the loaded
manifest still had legacy entries (a timestamp, the item keys list) is it rewritten.
"""
from .item import ManifestItem
manifest_archives = self.archives.finish(self)
manifest = ManifestItem(version=2, archives=manifest_archives, config=StableDict(self.config))
data = self.key.pack_metadata(manifest.as_dict())
self.id = self.repo_objs.id_hash(data)
if data == self._loaded_data:
logger.debug("manifest unchanged, not writing it.")
return
logger.debug("writing the manifest.")
robj = self.repo_objs.format(self.MANIFEST_ID, {}, data, ro_type=ROBJ_MANIFEST)
self.repository.put_manifest(robj)
self._loaded_data = data
+168 -40
View File
@@ -1,4 +1,5 @@
import io
import configparser
import os
import re
import sys
@@ -28,7 +29,6 @@ from .helpers import ProgressIndicatorPercent
from .helpers.lrucache import LRUCache
from .storelocking import Lock
from .logger import create_logger
from .manifest import NoManifestError
from .repoobj import RepoObj, OBJ_MAGIC, SUPPORTED_OBJ_VERSIONS
from .crypto.key import is_keyfile, store_hash, STORE_HASH_NAME, STORE_HASH_SIZE
@@ -767,6 +767,10 @@ class PackTracker:
pass
class _ConfigMissing(Exception):
"""internal: the store exists, but has no repository config object (see Repository._load_config)."""
class Repository:
"""borgstore-based key/value store."""
@@ -775,6 +779,11 @@ class Repository:
exit_mcode = 10
class IncompleteRepository(Error):
"""{} has no repository config."""
exit_mcode = 11
class CheckNeeded(ErrorWithTraceback):
"""Inconsistency detected. Please run "borg check {}"."""
@@ -866,6 +875,8 @@ class Repository:
self,
path_or_location,
create=False,
create_config=True,
allow_incomplete=False,
exclusive=False,
lock_wait=1.0,
lock=True,
@@ -938,6 +949,10 @@ class Repository:
self.permissions = None if location.proto == "rest" else permissions
self.store_opened = False
self.version = None
self.id = None
# the crypto suite of the repository's key, as recorded in the repository config (see save_config):
self.encryption = None # the "--encryption" name, e.g. "aes256-ocb"
self.id_hash = None # the "--id-hash" name, e.g. "sha256"
# long-running repository methods which emit log or progress output are responsible for calling
# the ._send_log method periodically to get log and progress output transferred to the borg client
# in a timely manner, in case we have a RemoteRepository.
@@ -945,7 +960,15 @@ class Repository:
self._send_log = send_log_cb or (lambda: None)
self.do_create = create
self.created = False
self.acceptable_repo_versions = (4,)
# create_config=False: create() does not write the repository config, the caller does that via
# save_config() (used by "borg repo-create", which writes it once the key exists, see create()).
self._create_config = create_config
self._config_written = False
# allow_incomplete=True: open() also accepts a store without repository config (see create()),
# so that "borg repo-delete --force" can destroy it. self.incomplete tells whether that happened.
self._allow_incomplete = allow_incomplete
self.incomplete = False
self.acceptable_repo_versions = (5,)
self.opened = False
self.lock = None
self.do_lock = lock
@@ -974,6 +997,9 @@ class Repository:
self.open(exclusive=bool(self.exclusive), lock_wait=self.lock_wait, lock=self.do_lock)
except Exception:
self.close(aborting=True)
if self.created:
# we just created the store, but could not open it: do not leave it behind (see create()).
self.store.destroy()
raise
return self
@@ -994,34 +1020,145 @@ class Repository:
return bin_to_hex(self.id)
def create(self):
"""Create a new empty repository"""
"""Create the store for a new repository, give it a fresh id and write the repository config.
The config has no key information yet (see save_config), so the repository can be opened and
used as a key/value store, but its key can not be loaded until save_config(key) was called.
With create_config=False (see __init__), the config is not written here, but by the caller via
save_config(). Until then, the store is not a repository (open() reports it as not a valid
repository): "borg repo-create" uses this, so that an interrupted repo-create does not leave a
repository behind.
If anything fails after the store was created, the store is destroyed again, so a failure (e.g.
disk full, permission denied) does not leave a store without config behind either.
"""
try:
self.store.create()
except StoreBackendAlreadyExists:
raise self.AlreadyExists(self.url)
self.store.open()
raise self._already_exists_error() from None
try:
self.store.store("config/readme", REPOSITORY_README.encode())
self.version = 4
self.store.store("config/version", str(self.version).encode())
self.store.store("config/id", bin_to_hex(os.urandom(32)).encode())
# we know repo/packs/ still does not have any chunks stored in it,
# but for some stores, there might be a lot of empty directories and
# listing them all might be rather slow, so we better cache an empty
# ChunkIndex from here so that the first repo operation does not have
# to build the ChunkIndex the slow way by listing all the directories.
from borg.cache import write_chunkindex_to_repo
# create all namespace directories in advance (saves ad-hoc mkdirs later, see borgstore). This
# also is what tells a store borg created apart from any other directory, see looks_like_borg_store().
self.store.create_levels()
self.store.open()
try:
self.version = 5
self.id = os.urandom(32)
if self._create_config:
self.save_config()
# we know repo/packs/ still does not have any chunks stored in it,
# but for some stores, there might be a lot of empty directories and
# listing them all might be rather slow, so we better cache an empty
# ChunkIndex from here so that the first repo operation does not have
# to build the ChunkIndex the slow way by listing all the directories.
from borg.cache import write_chunkindex_to_repo
write_chunkindex_to_repo(self, ChunkIndex(), clear=True, force_write=True)
finally:
self.store.close()
write_chunkindex_to_repo(self, ChunkIndex(), clear=True, force_write=True)
finally:
self.store.close()
except BaseException:
# do not leave the just created store behind (see above); the original error is what matters.
try:
self.store.destroy()
except Exception as exc:
logger.warning("could not remove the incompletely created store: %s", exc)
raise
def _already_exists_error(self):
# the store backend refused to create the store, e.g. because the directory is not empty. if there
# is a repository config, it is a repository. else we only know that there is no borg 2 repository:
# the directory may hold anything, e.g. a borg 1.x repository, or be the leftover of an interrupted
# repo-create (see create()).
try:
self.store.open()
try:
self.store.load("config/config")
except StoreObjectNotFound:
return self.IncompleteRepository(self.url)
finally:
self.store.close()
except StoreBackendError:
pass # can not look inside, so we do not know more
return self.AlreadyExists(self.url)
def save_config(self, key=None):
"""Store the repository config (the config/config store object).
It holds the repository version and id and the crypto suite of the repository's key (encryption
mode and id hash), so that the key class is known without reading any repository object.
Writing it is what makes the store a repository: open() requires it.
key: the repository's key, its crypto suite gets recorded. None: record the crypto suite known
from a previous config (if any) - without it, the repository can be opened, but its key can not
be loaded (see key_factory).
"""
if key is not None:
self.encryption, self.id_hash = key.ENC_NAME, key.IDHASH_NAME
config = configparser.ConfigParser(interpolation=None)
config.add_section("repository")
config.set("repository", "version", str(self.version))
config.set("repository", "id", bin_to_hex(self.id))
if self.encryption is not None and self.id_hash is not None:
config.set("repository", "encryption", self.encryption)
config.set("repository", "id_hash", self.id_hash)
with io.StringIO() as f:
for line in REPOSITORY_README.splitlines():
f.write(f"# {line}\n")
f.write("\n")
config.write(f)
self.store.store("config/config", f.getvalue().encode())
self._config_written = True
def _load_config(self):
try:
text = self.store.load("config/config").decode()
except StoreBackendDoesNotExist:
# A rest:// store's open() does not contact the server, so for rest:// a missing repository
# only shows up here, when the first request fails with BackendDoesNotExist (#10365).
raise self.DoesNotExist(str(self._location)) from None
except StoreObjectNotFound:
# the store exists, but has no repository config: a repository that lost its config, something
# that never was a borg 2 repository, or the leftover of an interrupted repo-create (see create()).
raise _ConfigMissing() from None
except UnicodeDecodeError:
raise self.InvalidRepository(str(self._location)) from None
config = configparser.ConfigParser(interpolation=None)
try:
config.read_string(text)
self.version = config.getint("repository", "version")
self.id = hex_to_bin(config.get("repository", "id"), length=32)
self.encryption = config.get("repository", "encryption", fallback=None)
self.id_hash = config.get("repository", "id_hash", fallback=None)
except (configparser.Error, ValueError):
raise self.InvalidRepository(str(self._location)) from None
if (self.encryption is None) != (self.id_hash is None):
# the crypto suite is recorded by both entries or by none, see save_config().
raise self.InvalidRepository(str(self._location))
def looks_like_borg_store(self):
"""Does the (opened, config-less) store have the packs, archives, index and config namespaces?
create() makes them, so every store borg created has them, even the leftover of an interrupted
repo-create. Any other directory (data, a home directory, a borg 1.x repository) does not, and
"borg repo-delete --force" must never destroy it.
"""
def namespace_exists(name):
try:
next(iter(self.store.list(name)), None) # an existing namespace lists (maybe nothing)
except StoreObjectNotFound:
return False
return True
return all(namespace_exists(name) for name in ("packs", "archives", "index", "config"))
def _set_id(self, id):
# for testing: change the id of an existing repository
assert self.opened
assert isinstance(id, bytes) and len(id) == 32
self.id = id
self.store.store("config/id", bin_to_hex(id).encode())
self.save_config()
def _lock_refresh(self):
if self.lock is not None:
@@ -1083,21 +1220,23 @@ class Repository:
raise self.DoesNotExist(str(self._location)) from None
else:
self.store_opened = True
try:
readme = self.store.load("config/readme").decode()
except (StoreObjectNotFound, StoreBackendDoesNotExist):
# A rest:// store's open() does not contact the server, so for rest:// a missing repository
# only shows up here, when the first request fails with BackendDoesNotExist (#10365).
raise self.DoesNotExist(str(self._location)) from None
if readme != REPOSITORY_README:
raise self.InvalidRepository(str(self._location))
self.version = int(self.store.load("config/version").decode())
if self.created and not self._config_written:
pass # create() just ran and set version and id; the config is written later by save_config().
else:
try:
self._load_config()
except _ConfigMissing:
if not self._allow_incomplete:
raise self.InvalidRepository(str(self._location)) from None
# a store without repository config: version and id are unknown, nothing gets locked, the
# only thing the caller may do with it is looks_like_borg_store() / destroy().
self.incomplete = True
return
if self.version not in self.acceptable_repo_versions:
self.close()
raise self.InvalidRepositoryConfig(
str(self._location), "repository version %d is not supported by this borg version" % self.version
)
self.id = hex_to_bin(self.store.load("config/id").decode(), length=32)
# important: lock *after* making sure that there actually is an existing, supported repository.
if lock:
self.lock = Lock(
@@ -2044,17 +2183,6 @@ class Repository:
if self.lock is not None:
self.lock.migrate_lock(old_id, new_id)
def get_manifest(self):
self._lock_refresh()
try:
return self.store.load("config/manifest")
except StoreObjectNotFound:
raise NoManifestError
def put_manifest(self, data):
self._lock_refresh()
return self.store.store("config/manifest", data)
def store_list(self, name, *, deleted=False):
self._lock_refresh()
try:
+11 -117
View File
@@ -19,8 +19,9 @@ from ...cache import (
read_chunkindex_from_repo,
write_chunkindex_invalid,
)
from ...crypto.key import RepositoryKeyInfoMissing
from ...constants import * # NOQA
from ...helpers import bin_to_hex, msgpack, CommandError, CorruptPack, Error, IntegrityError, sig_int
from ...helpers import bin_to_hex, CommandError, CorruptPack, Error, sig_int
from ...helpers import BackupDamagedChunksError
from ...helpers.passphrase import PassphraseWrong
from ...hashindex import ChunkIndex
@@ -739,63 +740,6 @@ def test_check_format_missing_archive_metadata(archivers, request):
assert "Analyzing archive archive2" in output # the intact archive still uses the given format
def test_missing_manifest(archivers, request):
archiver = request.getfixturevalue(archivers)
check_cmd_setup(archiver)
archive, repository = open_archive(archiver.repository_path, "archive1")
with repository:
if isinstance(repository, Repository):
repository.store_delete("config/manifest")
else:
repository.delete(Manifest.MANIFEST_ID, validate=None)
cmd(archiver, "check", exit_code=1)
output = cmd(archiver, "check", "-v", "--repair", exit_code=0)
assert "archive1" in output
assert "archive2" in output
cmd(archiver, "check", exit_code=0)
def test_corrupted_manifest(archivers, request):
archiver = request.getfixturevalue(archivers)
check_cmd_setup(archiver)
archive, repository = open_archive(archiver.repository_path, "archive1")
with repository:
manifest = repository.get_manifest()
corrupted_manifest = corrupt(manifest, len(manifest) - 1) # the manifest object is small, hit the ciphertext
repository.put_manifest(corrupted_manifest)
cmd(archiver, "check", exit_code=1)
output = cmd(archiver, "check", "-v", "--repair", exit_code=0)
assert "archive1" in output
assert "archive2" in output
cmd(archiver, "check", exit_code=0)
def test_spoofed_manifest(archivers, request):
archiver = request.getfixturevalue(archivers)
check_cmd_setup(archiver)
archive, repository = open_archive(archiver.repository_path, "archive1")
with repository:
manifest = Manifest.load(repository)
cdata = manifest.repo_objs.format(
Manifest.MANIFEST_ID,
{},
msgpack.packb({"version": 1, "archives": {}, "config": {}}),
# we assume that an attacker can put a file into backup src files that contains a fake manifest.
# but, the attacker can not influence the ro_type borg will use to store user file data:
ro_type=ROBJ_FILE_STREAM, # a real manifest is stored with ROBJ_MANIFEST
)
# maybe a repo-side attacker could manage to move the fake manifest file chunk over to the manifest ID.
# we simulate this here by directly writing the fake manifest data to the manifest ID.
repository.put_manifest(cdata)
# borg should notice that the manifest has the wrong ro_type.
cmd(archiver, "check", exit_code=1)
# borg check --repair should remove the corrupted manifest and rebuild a new one.
output = cmd(archiver, "check", "-v", "--repair", exit_code=0)
assert "archive1" in output
assert "archive2" in output
cmd(archiver, "check", exit_code=0)
def test_check_repair_rebuilds_corrupt_index(archivers, request):
# A corrupt index with all packs intact: the default (full) --repair rebuilds the index from the
# packs and persists it (via the archives check, see ArchiveChecker.finish), leaving the repository
@@ -876,25 +820,6 @@ def test_check_repository_only_repair_aborts_on_wrong_passphrase(archivers, requ
cmd(archiver, "check", "-v", "--repository-only", "--repair")
@pytest.mark.skip(reason="TODO: repair does not yet rewrite store-corrupted packs, refs #8572")
def test_manifest_rebuild_corrupted_chunk(archivers, request):
archiver = request.getfixturevalue(archivers)
check_cmd_setup(archiver)
archive, repository = open_archive(archiver.repository_path, "archive1")
with repository:
manifest = repository.get_manifest()
# flip a byte inside the encrypted manifest data so its integrity check fails and
# check --repair rebuilds the manifest.
corrupted_manifest = corrupt(manifest, len(manifest) // 3)
repository.put_manifest(corrupted_manifest)
corrupt_chunk_on_disk(repository, archive.id)
cmd(archiver, "check", exit_code=1)
output = cmd(archiver, "check", "-v", "--repair", exit_code=0)
assert "archive1" not in output
assert "archive2" in output
cmd(archiver, "check", exit_code=0)
def test_check_undelete_archives(archivers, request):
archiver = request.getfixturevalue(archivers)
check_cmd_setup(archiver) # creates archive1 and archive2
@@ -921,10 +846,6 @@ def test_spoofed_archive(archivers, request):
archive, repository = open_archive(archiver.repository_path, "archive1")
repo_objs = archive.repo_objs
with repository:
# attacker would corrupt or delete the manifest to trigger a rebuild of it:
manifest = repository.get_manifest()
corrupted_manifest = corrupt(manifest, len(manifest) - 1) # the manifest object is small, hit the ciphertext
repository.put_manifest(corrupted_manifest)
archive_dict = {
"command_line": "",
"item_ptrs": [],
@@ -948,8 +869,9 @@ def test_spoofed_archive(archivers, request):
),
)
repository.flush() # make the put durable before close()/the check below
cmd(archiver, "check", exit_code=1)
cmd(archiver, "check", "--repair", "--debug", exit_code=0)
# the attacker would hope that the search for lost archives picks the fake archive up, but
# borg notices that the object has the wrong ro_type.
cmd(archiver, "check", "--repair", "--find-lost-archives", "--debug", exit_code=0)
output = cmd(archiver, "repo-list")
assert "archive1" in output
assert "archive2" in output
@@ -1077,12 +999,12 @@ def test_check_without_repair_does_not_drop_a_pack_tail(archivers, request, monk
real_make_key = ArchiveChecker.make_key
def make_key(self, repository, manifest_only=False):
# fail the manifest_only read, the one the rebuild's validator needs, as an unreadable key
# would. check_cmd already read the key before the check (#1931), hence the full read below.
if manifest_only:
raise IntegrityError("no key")
return real_make_key(self, repository, manifest_only=manifest_only)
def make_key(self, repository):
# fail the reads before the index rebuild (the one the rebuild's validator needs), as a
# repository config without key info would. the full read after the rebuild succeeds.
if getattr(self, "chunks", None) is None:
raise RepositoryKeyInfoMissing("no key")
return real_make_key(self, repository)
real_build = archive_module.build_chunkindex_from_repo
rebuilds = []
@@ -1329,34 +1251,6 @@ def test_empty_repository(archivers, request):
cmd(archiver, "check", exit_code=1)
def test_manifest_with_timestamp_is_accepted(archivers, request):
# borg 1.x and older borg 2 versions wrote a "timestamp" entry into the manifest. it is not written
# anymore, but such manifests must still load (and get rewritten without it).
archiver = request.getfixturevalue(archivers)
check_cmd_setup(archiver)
with Repository(archiver.repository_path, exclusive=True) as repository:
manifest = Manifest.load(repository)
data = manifest.key.pack_metadata(
{
"version": 2,
"archives": {},
"config": {"item_keys": tuple(sorted(ITEM_KEYS))},
"timestamp": "2026-01-01T00:00:00.000000",
}
)
repository.put_manifest(manifest.repo_objs.format(Manifest.MANIFEST_ID, {}, data, ro_type=ROBJ_MANIFEST))
output = cmd(archiver, "repo-list")
assert "archive1" in output
create_src_archive(archiver, "archive3") # a writing command rewrites the manifest ...
dump_file = archiver.output_path + "/dump"
cmd(archiver, "debug", "dump-manifest", dump_file)
with open(dump_file) as f:
dump = f.read()
assert "timestamp" not in dump # ... without the timestamp
assert "item_keys" not in dump # ... and without the legacy item keys list
cmd(archiver, "check", exit_code=0)
def test_items_with_unknown_keys_are_kept(archivers, request):
# items with keys this borg version does not know (e.g. written by a newer borg) are not an error:
# check warns about them once per archive (rc stays 0) and --repair writes them back unchanged.
@@ -1,11 +1,7 @@
import json
import os
from configparser import ConfigParser
import pytest
from ...constants import * # NOQA
from ...helpers import bin_to_hex
from . import cmd, create_test_files, RK_ENCRYPTION
@@ -13,20 +9,3 @@ def corrupt_archiver(archiver):
create_test_files(archiver.input_path)
cmd(archiver, "repo-create", RK_ENCRYPTION)
archiver.cache_path = json.loads(cmd(archiver, "repo-info", "--json"))["cache"].get("path")
def test_old_version_interfered(archiver):
corrupt_archiver(archiver)
if archiver.cache_path is None:
pytest.skip("No cache path for this kind of cache implementation.")
# Modify the main manifest ID without touching the manifest ID in the integrity section.
# This happens if a version without integrity checking modifies the cache.
config_path = os.path.join(archiver.cache_path, "config")
config = ConfigParser(interpolation=None)
config.read(config_path)
config.set("cache", "manifest", bin_to_hex(bytes(32)))
with open(config_path, "w") as fd:
config.write(fd)
out = cmd(archiver, "repo-info")
assert "Cache integrity data not available: old Borg version modified the cache." in out
@@ -2120,16 +2120,3 @@ def test_files_cache_rebuild_group_by_invalid(archivers, request):
cmd(archiver, "repo-create", RK_ENCRYPTION)
output = cmd(archiver, "create", "--group-by", "", "home", "input", exit_code=2)
assert "At least one group-by key is required" in output
def test_create_does_not_rewrite_unchanged_manifest(archivers, request):
archiver = request.getfixturevalue(archivers)
if archiver.get_kind() != "local":
pytest.skip("opens the repository directly")
create_regular_file(archiver.input_path, "file1", size=1024)
cmd(archiver, "repo-create", RK_ENCRYPTION)
with Repository(archiver.repository_path, exclusive=True) as repository:
manifest_before = repository.get_manifest()
cmd(archiver, "create", "test", "input")
with Repository(archiver.repository_path, exclusive=True) as repository:
assert repository.get_manifest() == manifest_before
@@ -247,23 +247,6 @@ def test_debug_format_obj_respects_type(archivers, request):
assert meta_read["type"] == ROBJ_ARCHIVE_STREAM
def test_debug_dump_manifest(archivers, request):
archiver = request.getfixturevalue(archivers)
create_regular_file(archiver.input_path, "file1", size=1024 * 80)
cmd(archiver, "repo-create", RK_ENCRYPTION)
cmd(archiver, "create", "test", "input")
dump_file = archiver.output_path + "/dump"
output = cmd(archiver, "debug", "dump-manifest", dump_file)
assert output == ""
with open(dump_file) as f:
result = json.load(f)
assert "archives" in result
assert "config" in result
assert "timestamp" not in result
assert "version" in result
assert "item_keys" not in result["config"]
def test_debug_dump_archive(archivers, request):
archiver = request.getfixturevalue(archivers)
create_regular_file(archiver.input_path, "file1", size=1024 * 80)
@@ -135,3 +135,29 @@ def test_repo_create_refuse_to_overwrite_keyfile(archivers, request, monkeypatch
with open(keyfile) as file:
after = file.read()
assert before == after
def test_repo_create_failure_leaves_nothing_behind(archivers, request, monkeypatch):
# not only a cancelled, also a failed repo-create must not leave a (partial) repository or a keyfile.
archiver = request.getfixturevalue(archivers)
if archiver.EXE:
pytest.skip("patches object")
keys_dir = os.path.join(archiver.tmpdir, "keys")
monkeypatch.setenv("BORG_KEYS_DIR", keys_dir)
def failing_save_config(self, key=None):
raise OSError("simulated store failure while writing the config")
from ...repository import Repository
with patch.object(Repository, "save_config", failing_save_config):
if archiver.FORK_DEFAULT:
cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION, exit_code=2)
else:
with pytest.raises(OSError, match="simulated store failure"):
cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION)
assert not os.path.exists(archiver.repository_location)
assert not os.path.exists(keys_dir) or not os.listdir(keys_dir) # the keyfile written before the failure is gone
# and nothing stands in the way of creating the repository there now.
cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION)
assert os.listdir(keys_dir)
@@ -3,7 +3,7 @@ import os
import pytest
from ...constants import * # NOQA
from ...helpers import CancelledByUser
from ...helpers import CancelledByUser, Error
from . import create_regular_file, cmd, generate_archiver_tests, RK_ENCRYPTION
pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,binary") # NOQA
@@ -40,3 +40,44 @@ def test_delete_repo_force(archivers, request):
cmd(archiver, "repo-delete", "--force")
# Make sure the repository is gone
assert not os.path.exists(archiver.repository_path)
def test_delete_store_without_config(archivers, request):
# a store without repository config (e.g. the leftover of an interrupted repo-create, or a repository
# that lost its config) can only be deleted with --force.
from ...repository import Repository
archiver = request.getfixturevalue(archivers)
if archiver.EXE:
pytest.skip("creates the store via the Python API")
with Repository(archiver.repository_path, exclusive=True, create=True, create_config=False):
pass
if archiver.FORK_DEFAULT:
output = cmd(archiver, "repo-delete", exit_code=2)
assert "requires the --force option" in output
else:
with pytest.raises(Error, match="requires the --force option"):
cmd(archiver, "repo-delete")
cmd(archiver, "repo-delete", "--force", "--dry-run")
assert os.path.exists(archiver.repository_path)
cmd(archiver, "repo-delete", "--force")
assert not os.path.exists(archiver.repository_path)
# a directory with data in it does not look like a borg store: refused even with --force.
os.mkdir(archiver.repository_path)
create_regular_file(archiver.repository_path, "file", contents=b"some data")
if archiver.FORK_DEFAULT:
output = cmd(archiver, "repo-delete", "--force", exit_code=2)
assert "does not look like a borg store" in output
else:
with pytest.raises(Error, match="does not look like a borg store"):
cmd(archiver, "repo-delete", "--force")
assert os.listdir(archiver.repository_path) == ["file"]
os.unlink(os.path.join(archiver.repository_path, "file"))
os.rmdir(archiver.repository_path)
# a repository with an archive that lost its config: --force destroys it.
cmd(archiver, "repo-create", RK_ENCRYPTION)
create_regular_file(archiver.input_path, "file1", size=1024)
cmd(archiver, "create", "test", "input")
os.unlink(os.path.join(archiver.repository_path, "config", "config"))
cmd(archiver, "repo-delete", "--force")
assert not os.path.exists(archiver.repository_path)
@@ -32,13 +32,14 @@ def test_return_codes(archivers, request):
def test_exit_codes(archivers, request, monkeypatch):
archiver = request.getfixturevalue(archivers)
# we create the repo path, but do NOT initialize the borg repo,
# so the borg create commands are expected to fail with DoesNotExist (was: InvalidRepository in borg 1.4).
# we create the repo path, but do NOT initialize the borg repo: the store exists, but has no repository
# config, so the borg create commands are expected to fail with InvalidRepository (DoesNotExist is only
# for a store that does not exist at all).
os.makedirs(archiver.repository_path)
monkeypatch.setenv("BORG_EXIT_CODES", "classic")
cmd(archiver, "create", "archive", "input", fork=True, exit_code=EXIT_ERROR)
monkeypatch.setenv("BORG_EXIT_CODES", "modern")
cmd(archiver, "create", "archive", "input", fork=True, exit_code=Repository.DoesNotExist.exit_mcode)
cmd(archiver, "create", "archive", "input", fork=True, exit_code=Repository.InvalidRepository.exit_mcode)
def test_print_warning_instance_does_not_retain_exception():
-5
View File
@@ -77,11 +77,6 @@ def test_prepare_is_noop():
m.assert_not_called()
def test_finish_returns_empty_dict():
ar, _, manifest = _archives()
assert ar.finish(manifest) == {}
def test_ids_empty():
ar, _, _ = _archives()
assert list(ar.ids()) == []
+3 -6
View File
@@ -51,16 +51,13 @@ class TestAdHocWithFilesCache:
@pytest.fixture
def manifest(self, repository, key):
Manifest(key, repository).write()
repository.save_config(key)
return Manifest.load(repository, key=key)
@pytest.fixture
def cache(self, repository, key, manifest):
return AdHocWithFilesCache(manifest)
def test_does_not_contain_manifest(self, cache):
assert not cache.seen_chunk(Manifest.MANIFEST_ID)
def test_seen_chunk_add_chunk_size(self, cache):
assert cache.add_chunk(H(1), {}, b"5678", stats=Statistics()) == (H(1), 4)
@@ -478,7 +475,7 @@ def test_close_consolidates_fragments_across_sessions(tmp_path, monkeypatch):
loc = os.fspath(tmp_path / "repository")
with Repository(loc, exclusive=True, create=True) as repository:
key = AESOCBKey.create(repository, TestKey.MockArgs())
Manifest(key, repository).write()
repository.save_config(key)
all_ids = []
for s in range(5): # each session adds 100 new chunks (< MIN), so fragments must be consolidated
@@ -743,7 +740,7 @@ def test_files_cache_save_tolerates_missing_chunk(tmp_path, monkeypatch):
loc = os.fspath(tmp_path / "repository")
with Repository(loc, exclusive=True, create=True) as repository:
key = AESOCBKey.create(repository, TestKey.MockArgs())
Manifest(key, repository).write()
repository.save_config(key)
with Repository(loc, exclusive=True) as repository:
manifest = Manifest.load(repository, key=key)
+23 -83
View File
@@ -9,22 +9,21 @@ from ...crypto.key import BLAKE3_MT_THRESHOLD_KIB, get_blake3_mt_threshold
from ...crypto.key import ChecksumKey, Blake3ChecksumKey, keyfile_parse
from ...crypto.key import AuthenticatedKey, Blake3AuthenticatedKey
from ...crypto.key import AESCTRKey, Blake2AESCTRKey, Blake2AuthenticatedKey
from ...crypto.key import LegacyPlaintextKey, LegacyAuthenticatedKey
from ...crypto.key import LegacyAuthenticatedKey
from ...crypto.key import AEADKeyBase
from ...crypto.key import AESOCBKey, CHPOKey, Blake3AESOCBKey, Blake3CHPOKey
from ...crypto.key import AES_OCB_MAX_SESSION_BLOCKS
from ...crypto.key import ID_HMAC_SHA_256, ID_BLAKE2b_256, ID_BLAKE3_256
from ...crypto.key import UnsupportedManifestError, UnsupportedKeyFormatError, UnsupportedPayloadError
from ...crypto.key import RepoKeyNotFoundError
from ...crypto.key import identify_key, key_from_repository
from ...crypto.key import identify_key, key_class_for, key_class_of, key_factory, RepositoryKeyInfoMissing
from ...crypto.key import AVAILABLE_KEY_TYPES
from ...crypto.low_level import IntegrityError as IntegrityErrorBase
from ...helpers import Error
from ...helpers import IntegrityError
from ...helpers import Location
from ...helpers import msgpack
from ...manifest import NoManifestError
from ...repoobj import RepoObj
from ...constants import KEY_ALGORITHMS, KeyBlobStorage, KeyType, ROBJ_FILE_STREAM, ROBJ_MANIFEST
from ...constants import KEY_ALGORITHMS, KeyBlobStorage, KeyType
from ...helpers import hex_to_bin, bin_to_hex
@@ -602,21 +601,6 @@ def test_legacy_authenticated_no_key_key_gone(cls, monkeypatch, tmp_path):
assert bytes(key.decrypt(None, envelope)) == payload
def test_dropped_borg2_beta_key_types(tmpdir):
# the borg2 beta "none"/"authenticated" formats were dropped, see #9104. A borg2 repository
# using them must be refused instead of being read with the legacy classes.
from ...repoobj import RepoObj
from ...crypto.key import key_factory
for legacy_cls in (LegacyPlaintextKey, LegacyAuthenticatedKey):
key = legacy_cls(MagicMock(id=bytes(32)))
if legacy_cls is LegacyAuthenticatedKey:
key.id_key = bytes(32)
manifest_chunk = RepoObj(key).format(bytes(32), {}, b"manifest", ro_type=ROBJ_MANIFEST)
with pytest.raises(UnsupportedPayloadError):
key_factory(MagicMock(id=bytes(32)), manifest_chunk, ro_cls=RepoObj)
def test_dropped_blake3_authenticated_type_byte():
with pytest.raises(UnsupportedPayloadError):
identify_key(bytes([KeyType.DROPPED_BLAKE3AUTHENTICATED]) + b"payload")
@@ -669,70 +653,26 @@ def test_argon2_wrong_passphrase_returns_none(monkeypatch):
assert key.decrypt_key_file(a2b_base64(saved_b64), "wrong passphrase") is None
class StoredObjectsRepository:
"""A repository with a stored manifest (None: no manifest) and objects by chunk id."""
def __init__(self, manifest, objects):
self.manifest = manifest
self.objects = objects
def get_manifest(self):
if self.manifest is None:
raise NoManifestError
return self.manifest
def get(self, id):
return self.objects[id]
def list(self, limit=None):
return [(id, len(obj)) for id, obj in self.objects.items()][:limit]
@pytest.mark.parametrize("cls", AVAILABLE_KEY_TYPES)
def test_key_class_for_names_every_creatable_suite(cls):
# the repository config records the crypto suite by the --encryption / --id-hash names
assert key_class_for(cls.ENC_NAME, cls.IDHASH_NAME) is cls
def stored_object(key_cls, id):
return RepoObj(key_cls(MagicMock(id=bytes(32)))).format(id, {}, b"data", ro_type=ROBJ_FILE_STREAM)
def test_key_class_for_unknown_suite():
from ...repository import Repository
assert key_class_for("rot13", "sha256") is None
repository = MagicMock(encryption="rot13", id_hash="sha256")
with pytest.raises(Repository.InvalidRepositoryConfig, match="unsupported crypto suite"):
key_class_of(repository)
def with_key_type(cdata, key_type):
"""Return cdata with its key type byte, the first byte of the data slot, set to key_type."""
offset = len(cdata) - len(RepoObj.extract_crypted_data(cdata))
return cdata[:offset] + bytes([key_type]) + cdata[offset + 1 :]
def test_key_from_repository_reads_the_key_type_from_the_manifest():
objects = {b"o" * 32: stored_object(ChecksumKey, b"o" * 32)}
repository = StoredObjectsRepository(stored_object(Blake3ChecksumKey, bytes(32)), objects)
assert isinstance(key_from_repository(repository), Blake3ChecksumKey)
def test_key_from_repository_skips_objects_that_do_not_identify_a_key_type():
good = stored_object(ChecksumKey, b"g" * 32)
objects = {
b"d" * 32: b"damaged",
b"u" * 32: with_key_type(good, KeyType.DROPPED_BLAKE3AUTHENTICATED),
b"g" * 32: good,
}
repository = StoredObjectsRepository(b"damaged manifest", objects)
assert isinstance(key_from_repository(repository), ChecksumKey)
def test_key_from_repository_raises_if_no_object_identifies_the_key_type():
objects = {b"g" * 32: stored_object(ChecksumKey, b"g" * 32)}
with pytest.raises(IntegrityError):
key_from_repository(StoredObjectsRepository(None, objects), ids=()) # the manifest only
with pytest.raises(IntegrityError):
key_from_repository(StoredObjectsRepository(None, {b"d" * 32: b"damaged"}))
def test_key_from_repository_loads_the_key_once(monkeypatch):
ids = (b"a" * 32, b"b" * 32)
repository = StoredObjectsRepository(None, {id: stored_object(ChecksumKey, id) for id in ids})
detected = []
def detect(repository, manifest_data, *, other=False):
detected.append(manifest_data)
raise IntegrityError("the key can not be loaded")
monkeypatch.setattr(ChecksumKey, "detect", detect)
with pytest.raises(IntegrityError, match="the key can not be loaded"):
key_from_repository(repository)
assert len(detected) == 1
def test_key_class_of_needs_key_info():
repository = MagicMock(encryption=None, id_hash=None)
with pytest.raises(RepositoryKeyInfoMissing):
key_class_of(repository)
with pytest.raises(RepositoryKeyInfoMissing):
key_factory(repository)
repository = MagicMock(encryption="none-sha256", id_hash="sha256")
assert key_class_of(repository) is ChecksumKey
@@ -87,12 +87,6 @@ def test_prepare():
assert la._archives == {"x": {"id": _id(5), "time": TS}}
def test_finish():
la, _, manifest = _archives([("a", _id(1), TS)])
result = la.finish(manifest)
assert result == {"a": {"id": _id(1), "time": TS}}
def test_ids():
la, _, _ = _archives([("a", _id(1), TS), ("b", _id(2), TS)])
assert list(la.ids()) == [_id(1), _id(2)]
+1 -13
View File
@@ -1,6 +1,6 @@
import pytest
from ..constants import ROBJ_FILE_STREAM, ROBJ_MANIFEST, ROBJ_ARCHIVE_META
from ..constants import ROBJ_FILE_STREAM, ROBJ_ARCHIVE_META
from ..crypto.key import AESOCBKey, ChecksumKey, AuthenticatedKey, CHPOKey, LegacyPlaintextKey
from ..helpers import CompressionSpec, msgpack
from ..helpers.errors import Error, IntegrityError
@@ -176,18 +176,6 @@ def test_malformed_object_inconsistent_sizes(key):
repo_objs.parse(id, hdr, ro_type=ROBJ_FILE_STREAM)
def test_spoof_manifest(key):
repo_objs = RepoObj(key)
data = b"fake or malicious manifest data" # File content could be provided by an attacker.
id = repo_objs.id_hash(data)
# Create a repository object containing user data (file content data).
cdata = repo_objs.format(id, {}, data, ro_type=ROBJ_FILE_STREAM)
# Let's assume an attacker managed to replace the manifest with that repository object.
# As Borg always gives the ro_type it intends to read, this should fail:
with pytest.raises(IntegrityError):
repo_objs.parse(id, cdata, ro_type=ROBJ_MANIFEST)
def test_spoof_archive(key):
repo_objs = RepoObj(key)
data = b"fake or malicious archive data" # File content could be provided by an attacker.
+141
View File
@@ -2735,3 +2735,144 @@ def test_superseded_gap_ranges_ends_at_an_object_reaching_past_the_gap(tmp_path,
f"pack {bin_to_hex(THIS_PACK)}: object reaching past its gap at offset 0 in a gap, "
f"keeping the remaining {len(obj) - 1} bytes of the gap." in caplog.text
)
def test_config_roundtrip(tmp_path):
# the config/config store object holds version, id and the crypto suite of the key, see Repository.save_config
from ..crypto.key import Blake3CHPOKey
location = os.fspath(tmp_path / "repo")
with Repository(location, exclusive=True, create=True) as repository:
# create() wrote a config without key info:
text = repository.store_load("config/config").decode()
assert text.startswith("# This is a Borg Backup repository.\n")
assert "[repository]\nversion = 5\n" in text
assert "encryption" not in text and "id_hash" not in text
assert (repository.encryption, repository.id_hash) == (None, None)
key = Blake3CHPOKey(repository)
repository.save_config(key)
id = repository.id
text = repository.store_load("config/config").decode()
assert "encryption = chacha20-poly1305\nid_hash = blake3\n" in text
with Repository(location, exclusive=True) as repository:
assert repository.version == 5
assert repository.id == id
assert (repository.encryption, repository.id_hash) == ("chacha20-poly1305", "blake3")
repository.save_config() # without a key: the recorded crypto suite is kept
with Repository(location, exclusive=True) as repository:
assert (repository.encryption, repository.id_hash) == ("chacha20-poly1305", "blake3")
def test_store_without_config_is_not_a_repository(tmp_path):
# with create_config=False (as repo-create uses it), create() does not write the config, save_config()
# does: until then, the store is not a repository.
location = os.fspath(tmp_path / "repo")
with Repository(location, exclusive=True, create=True, create_config=False):
pass
assert os.path.exists(location)
with pytest.raises(Repository.InvalidRepository):
with Repository(location, exclusive=True):
pass
with pytest.raises(Repository.IncompleteRepository):
with Repository(location, exclusive=True, create=True):
pass
# allow_incomplete (as repo-delete --force uses it): opens, tells, and the store can be destroyed.
with Repository(location, exclusive=True, allow_incomplete=True) as repository:
assert repository.incomplete and repository.version is None and repository.id is None
assert repository.looks_like_borg_store() # create() made the namespaces
repository.destroy()
assert not os.path.exists(location)
def test_repository_that_lost_its_config(tmp_path):
# a repository with data whose config got lost is not a valid repository (not "does not exist").
location = os.fspath(tmp_path / "repo")
with Repository(location, exclusive=True, create=True) as repository:
repository.put(H(1), fchunk(b"DATA", chunk_id=H(1)))
repository.flush()
os.unlink(os.path.join(location, "config", "config"))
with pytest.raises(Repository.InvalidRepository):
with Repository(location, exclusive=True):
pass
with Repository(location, exclusive=True, allow_incomplete=True) as repository:
assert repository.incomplete
assert repository.looks_like_borg_store()
def test_create_refuses_non_empty_directory(tmp_path):
# a non-empty directory that is no repository: the backend refuses to create the store there, and borg
# only knows that there is no repository config (it can not tell what the directory holds).
location = os.fspath(tmp_path / "data")
os.mkdir(location)
with open(os.path.join(location, "file"), "w") as f:
f.write("some data")
with pytest.raises(Repository.IncompleteRepository):
with Repository(location, exclusive=True, create=True):
pass
assert os.listdir(location) == ["file"] # and it leaves the directory alone
with Repository(location, exclusive=True, allow_incomplete=True) as repository:
assert repository.incomplete
assert not repository.looks_like_borg_store() # no borg namespaces: never destroyed by borg
assert os.listdir(location) == ["file"]
# a repository (with config) is reported as existing, too:
location = os.fspath(tmp_path / "repo")
with Repository(location, exclusive=True, create=True):
pass
with pytest.raises(Repository.AlreadyExists):
with Repository(location, exclusive=True, create=True):
pass
def test_open_refuses_bad_config(tmp_path):
location = os.fspath(tmp_path / "repo")
with Repository(location, exclusive=True, create=True):
pass
config_path = os.path.join(location, "config", "config") # the store is a directory, write the object directly
with open(config_path, "rb") as f:
good = f.read()
def write_config(data):
with open(config_path, "wb") as f:
f.write(data)
write_config(good.replace(b"version = 5", b"version = 4"))
with pytest.raises(Repository.InvalidRepositoryConfig):
with Repository(location, exclusive=True):
pass
bad_configs = [
b"", # no [repository] section
b"[repository]\nversion = 5\n", # no id
b"[repository]\nversion = x\nid = 00\n", # invalid version and id
good + b"encryption = aes256-ocb\n", # key info must be complete (encryption AND id_hash)
good + b"id_hash = sha256\n",
b"version = 5\n", # not an INI file (no section header)
b"\xff\xfe", # not even text
]
for bad in bad_configs:
write_config(bad)
with pytest.raises(Repository.InvalidRepository):
with Repository(location, exclusive=True):
pass
write_config(good)
with Repository(location, exclusive=True):
pass
def test_create_failure_leaves_no_store_behind(tmp_path, monkeypatch):
# a failure inside create() after the store was created (e.g. disk full while writing the empty chunk
# index) must not leave a store without config behind.
from .. import cache as cache_module
def failing_write(*args, **kwargs):
raise OSError("simulated disk full")
monkeypatch.setattr(cache_module, "write_chunkindex_to_repo", failing_write)
location = os.fspath(tmp_path / "repo")
with pytest.raises(OSError, match="simulated disk full"):
with Repository(location, exclusive=True, create=True):
pass
assert not os.path.exists(location)
monkeypatch.undo()
with Repository(location, exclusive=True, create=True): # and creating it afterwards works
pass
assert os.path.exists(os.path.join(location, "config", "config"))