Skip to content

Commit a68d943

Browse files
Merge pull request #10441 from ThomasWaldmann/repo-defaults-mandatory
repo-create: always write config/defaults, so its removal is detected, refs #346
2 parents 57c0a6f + 26ff43f commit a68d943

8 files changed

Lines changed: 131 additions & 23 deletions

File tree

‎docs/internals/data-structures.rst‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ config/
4141
the repository config (see :ref:`repo_config`), a text object
4242
defaults
4343
the repository defaults (see :ref:`repo_defaults`), in the key's store object
44-
envelope (see below). Only present if ``borg repo-create`` was given a default.
44+
envelope (see below). ``borg repo-create`` always writes it.
4545
space-reserve.N
4646
purely random binary data to reserve space, e.g. for disk-full emergencies.
4747
These objects are created and removed by ``borg repo-space``.
@@ -132,8 +132,9 @@ packs; any other command that needs the chunks index aborts, except ``borg compa
132132
and ``borg repo-compress``, which rebuild it from the packs, as they rewrite the whole
133133
chunks index anyway (under an exclusive lock). A corrupted cache is ignored and
134134
rebuilt. A lock object that fails the authentication is treated as a foreign exclusive
135-
lock, see :ref:`storelocking`. Commands that use ``config/defaults`` abort if it fails
136-
the authentication. The ``chunkindex-invalid`` marker has no content and is
135+
lock, see :ref:`storelocking`. The commands that use ``config/defaults`` abort if it is
136+
missing or fails the authentication, ``borg check --repair`` replaces it by empty defaults
137+
(see :ref:`repo_defaults`). The ``chunkindex-invalid`` marker has no content and is
137138
stored as is.
138139

139140

@@ -339,8 +340,13 @@ for it.
339340
Unlike the repository config, which borg must read before it knows the key, the
340341
defaults are stored in the :ref:`store object envelope <store_object_envelope>`,
341342
so they are authenticated: an attacker with write access to the storage can not
342-
change them (e.g. remove an ``obfuscate`` compression) without being noticed. A
343-
repository without the object has no defaults.
343+
change them (e.g. remove an ``obfuscate`` compression) without being noticed.
344+
``borg repo-create`` always writes the object, also when no default was given (an
345+
empty dict), so removing it is noticed as well: the commands that use the defaults
346+
refuse to run if the object is missing or fails the authentication. ``borg check``
347+
reports such an object and ``borg check --repair`` replaces it by empty defaults, so
348+
the repository can be used again (with the built-in defaults). Like the repository
349+
config, the defaults themselves can not be restored.
344350

345351
.. _archive:
346352

‎docs/internals/frontends.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,8 @@ Errors
848848
{} has no repository config.
849849
Repository.CheckNeeded rc: 12 traceback: yes
850850
Inconsistency detected. Please run "borg check {}".
851+
Repository.DefaultsMissing rc: 34 traceback: no
852+
Repository {} has no config/defaults object, run "borg check --repair" to store empty defaults.
851853
Repository.DoesNotExist rc: 13 traceback: no
852854
Repository {} does not exist.
853855
Repository.InsufficientFreeSpaceError rc: 14 traceback: no

‎src/borg/archiver/_common.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,15 @@ def wrapper(self, args, **kwargs):
183183
ro_cls = RepoObj1
184184
manifest_ = Manifest.load(repository, other=False, ro_cls=ro_cls)
185185
kwargs["manifest"] = manifest_
186+
if secure:
187+
assert_secure(repository, manifest_)
188+
# the repository defaults are read only after the security checks passed.
186189
if "compression" in args:
187190
if args.compression is None: # not given, see default_compression()
188191
args.compression = default_compression(repository)
189192
manifest_.repo_objs.compressor = args.compression.compressor
190193
if "chunker_params" in args and args.chunker_params == DEFAULT_CHUNKER_PARAMS:
191194
args.chunker_params = default_chunker_params(repository)
192-
if secure:
193-
assert_secure(repository, manifest_)
194195
if cache:
195196
with Cache(
196197
repository,

‎src/borg/archiver/check_cmd.py‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,51 @@
44
from ..archive import ArchiveChecker
55
from ..constants import * # NOQA
66
from ..crypto.key import key_factory
7-
from ..helpers import set_ec, EXIT_WARNING, CancelledByUser, CommandError, Error
7+
from ..helpers import set_ec, EXIT_WARNING, CancelledByUser, CommandError, Error, IntegrityError
88
from ..helpers import relative_time_marker_validator, yes, ArchiveFormatter, sig_int
99
from ..helpers.argparsing import ArgumentParser
1010
from ..helpers.time import archive_ts_now, calculate_relative_offset
1111
from ..repoobj import RepoObj, object_validator
12+
from ..repository import Repository, DEFAULTS_NAME
1213

1314
from ..logger import create_logger
1415

1516
logger = create_logger()
1617

1718

19+
def check_repository_defaults(repository, *, repair):
20+
"""Verify the repository defaults object (config/defaults, see Repository.load_defaults).
21+
22+
"borg repo-create" always writes it, so a missing object was removed (or lost), like one that fails
23+
the authentication of the key's envelope or does not deserialize. Such an object is an error: the
24+
commands using the defaults refuse to run. With repair, empty defaults are stored instead, so the
25+
repository can be used again (with the built-in defaults).
26+
27+
Returns False if a problem was found and not repaired.
28+
"""
29+
try:
30+
repository.load_defaults()
31+
except Repository.DefaultsMissing:
32+
problem = "is missing"
33+
except IntegrityError:
34+
problem = "fails the authentication"
35+
except Repository.InvalidRepositoryConfig:
36+
problem = "is malformed"
37+
else:
38+
return True
39+
if not repair:
40+
logger.error(
41+
f'Repository defaults object {DEFAULTS_NAME} {problem}. Run "borg check --repair" to store empty defaults.'
42+
)
43+
return False
44+
logger.warning(
45+
f"Repository defaults object {DEFAULTS_NAME} {problem}, storing empty defaults. "
46+
"The defaults set by borg repo-create are lost, the built-in defaults are used now."
47+
)
48+
repository.save_defaults({})
49+
return True
50+
51+
1852
class CheckMixIn:
1953
@with_repository(exclusive=True, manifest=False)
2054
def do_check(self, args, repository):
@@ -91,6 +125,8 @@ def do_check(self, args, repository):
91125
set_ec(EXIT_WARNING)
92126
if sig_int: # repository check interrupted; skip the archive check
93127
raise Error("Got Ctrl-C / SIGINT.")
128+
if not check_repository_defaults(repository, repair=args.repair):
129+
set_ec(EXIT_WARNING)
94130
if not args.repo_only and not archive_checker.check(
95131
repository,
96132
verify_data=args.verify_data,
@@ -128,7 +164,9 @@ def build_parser_check(self, subparsers, common_parser, mid_common_parser):
128164
not a MAC, this step does not detect tampering of the packs. The index objects
129165
are also authenticated with the key when they are loaded for that cross-check.
130166
A corrupt index ends the check after this step, as the archives check needs it,
131-
unless ``--repair`` is given (see below). Running the repository check can
167+
unless ``--repair`` is given (see below). This step also verifies the repository
168+
defaults object (see ``borg repo-create``): it must be present and authenticate
169+
with the key. Running the repository check can
132170
be split into multiple partial checks using ``--max-duration``.
133171
For ssh:// repositories, the server computes the hashes, so the pack contents do
134172
not have to travel over the network. For other remote backends, borg usually has
@@ -269,6 +307,8 @@ def build_parser_check(self, subparsers, common_parser, mid_common_parser):
269307
index entries of the chunks stored in missing packs (packs the index references,
270308
but that are absent from the repository). Only a full ``borg check --repair``
271309
repairs the archives that reference these chunks, ``--repository-only`` does not.
310+
A missing or corrupt repository defaults object is replaced by empty defaults, so
311+
the repository can be used again; the commands then use the built-in defaults.
272312
273313
2. When checking the consistency and correctness of archives, repair mode might
274314
remove whole archives from the manifest if their archive metadata chunk is

‎src/borg/archiver/repo_create_cmd.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ def do_repo_create(self, args, repository, *, other_repository=None, other_manif
4646
defaults["compression"] = str(args.compression)
4747
if args.chunker_params not in (None, DEFAULT_CHUNKER_PARAMS):
4848
defaults["chunker_params"] = ",".join(str(p) for p in args.chunker_params)
49-
if defaults:
50-
repository.save_defaults(defaults)
49+
# always written, also when empty: a missing object means it was removed, see load_defaults().
50+
repository.save_defaults(defaults)
5151
# we know repo/packs/ still does not have any chunks stored in it, but for some stores, there
5252
# might be a lot of empty directories and listing them all might be rather slow, so we better
5353
# store an empty ChunkIndex now, so that the first repo operation does not have to build the
@@ -218,8 +218,11 @@ def build_parser_repo_create(self, subparsers, common_parser, mid_common_parser)
218218
Using the same chunker parameters is important for deduplication.
219219
``borg repo-info`` shows the defaults.
220220
221-
The defaults are stored in the repository and protected by the repository key, so nobody without
222-
the key can change them (e.g. remove an ``obfuscate`` compression) without being noticed.
221+
The defaults are stored in the repository and protected by the repository key: changing them
222+
(e.g. removing an ``obfuscate`` compression) needs the key. The defaults object is always
223+
written, also when no default was given, so removing it is noticed, too: the commands refuse
224+
to run if it is missing or fails the authentication. ``borg check`` reports such an object,
225+
``borg check --repair`` replaces it by empty defaults (the built-in defaults are used then).
223226
224227
Creating a related repository
225228
+++++++++++++++++++++++++++++

‎src/borg/repository.py‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,11 @@ class LegacyRepository(Error):
891891

892892
exit_mcode = 29
893893

894+
class DefaultsMissing(Error):
895+
"""Repository {} has no config/defaults object, run "borg check --repair" to store empty defaults."""
896+
897+
exit_mcode = 34
898+
894899
# Whole packs kept in memory for reads; the least recently used is evicted first.
895900
# Memory use is this count times the pack size.
896901
PACK_READER_CACHE_SIZE = 3
@@ -2466,29 +2471,35 @@ def save_defaults(self, defaults):
24662471
"""Store the repository defaults (the config/defaults store object).
24672472
24682473
defaults: a dict mapping option names to their default values as strings, e.g.
2469-
{"compression": "zstd,3"}. The commands use such a default if the option was not given (see
2470-
with_repository). Unlike config/config, which is read before the key is known, the object is
2471-
stored in the key's envelope (see store_encrypt_store), so nobody without the key can change the
2472-
defaults (e.g. remove an "obfuscate" compression) without being noticed.
2474+
{"compression": "zstd,3"}, or {} for no defaults. The commands use such a default if the option
2475+
was not given (see with_repository).
2476+
2477+
"borg repo-create" always writes the object, so a repository without it lost it (see
2478+
load_defaults). Unlike config/config, which is read before the key is known, the object is stored
2479+
in the key's envelope (see store_encrypt_store), so changing the defaults (e.g. removing an
2480+
"obfuscate" compression) needs the key: a changed object fails the authentication, a removed one
2481+
is missing, and the commands refuse to run either way.
24732482
"""
24742483
self.store_encrypt_store(DEFAULTS_NAME, msgpack.packb(defaults))
24752484
self._defaults = dict(defaults)
24762485

24772486
def load_defaults(self):
2478-
"""Return the repository defaults stored by save_defaults(), or {} if there are none.
2487+
"""Return the repository defaults stored by save_defaults(), {} if there are none.
24792488
24802489
The store object is only read once, later calls return the same defaults.
24812490
2482-
Raises IntegrityError if the envelope authentication fails, InvalidRepositoryConfig if the
2483-
content is not a dict of strings.
2491+
Raises DefaultsMissing if the object is missing (it was removed or lost, "borg check --repair"
2492+
stores empty defaults), IntegrityError if the envelope authentication fails,
2493+
InvalidRepositoryConfig if the content is not a dict of strings.
24842494
"""
24852495
if self._defaults is not None:
24862496
return dict(self._defaults)
24872497
try:
24882498
data = self.store_load_decrypt(DEFAULTS_NAME)
24892499
except StoreObjectNotFound:
2490-
self._defaults = {}
2491-
return {}
2500+
raise self.DefaultsMissing(self._location.canonical_path()) from None
2501+
except IntegrityError as err:
2502+
raise IntegrityError(f'{err.args[0]}. Run "borg check --repair" to store empty defaults.') from err
24922503
try:
24932504
defaults = msgpack.unpackb(data)
24942505
except msgpack.UnpackException:

‎src/borg/testsuite/archiver/check_cmd_test.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import gc
2+
import os
23
from pathlib import Path
34
import re
45
import shutil
@@ -70,6 +71,31 @@ def check_cmd_setup(archiver):
7071
create_src_archive(archiver, "archive2")
7172

7273

74+
@pytest.mark.parametrize("damage", ["missing", "corrupt"])
75+
def test_check_repository_defaults(archivers, request, damage):
76+
# repo-create always writes config/defaults, so a missing object was removed, like one that fails the
77+
# authentication. check reports it, check --repair replaces it by empty defaults (the set defaults
78+
# are lost), so the repository can be used again.
79+
archiver = request.getfixturevalue(archivers)
80+
cmd(archiver, "repo-create", RK_ENCRYPTION, "--compression=zstd,5")
81+
create_src_archive(archiver, "archive1")
82+
assert "Default compression: zstd,5" + os.linesep in cmd(archiver, "repo-info")
83+
with open_repository(archiver) as repository:
84+
if damage == "missing":
85+
repository.store_delete("config/defaults")
86+
else:
87+
repository.store_store("config/defaults", corrupt(repository.store_load("config/defaults"), -1))
88+
problem = "is missing" if damage == "missing" else "fails the authentication"
89+
output = cmd(archiver, "check", exit_code=EXIT_WARNING)
90+
assert f"config/defaults {problem}" in output
91+
assert "borg check --repair" in output
92+
output = cmd(archiver, "check", "--repair", exit_code=0)
93+
assert f"config/defaults {problem}, storing empty defaults" in output
94+
cmd(archiver, "check", exit_code=0)
95+
assert "Default compression: lz4 (built-in)" + os.linesep in cmd(archiver, "repo-info")
96+
create_src_archive(archiver, "archive2") # the repository is usable again
97+
98+
7399
def test_check_usage(archivers, request):
74100
archiver = request.getfixturevalue(archivers)
75101
check_cmd_setup(archiver)

‎src/borg/testsuite/archiver/repo_create_cmd_test.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ def test_repo_create_without_default_compression(archivers, request):
240240
create_regular_file(archiver.input_path, "file1", size=1024 * 80)
241241
cmd(archiver, "repo-create", RK_ENCRYPTION)
242242
with open_repository(archiver) as repository:
243-
assert repository.load_defaults() == {}
243+
assert repository.load_defaults() == {} # the object exists (else DefaultsMissing), but is empty
244244
output = cmd(archiver, "repo-info")
245245
assert "Default compression: lz4 (built-in)" + os.linesep in output
246246
assert "Default chunker params: %s,%d,%d,%d,%d (built-in)" % CHUNKER_PARAMS + os.linesep in output
@@ -333,3 +333,22 @@ def test_default_chunker_params_invalid(archivers, request):
333333
else:
334334
with pytest.raises(Repository.InvalidRepositoryConfig):
335335
cmd(archiver, "create", "test", "input")
336+
337+
338+
def test_defaults_missing(archivers, request):
339+
# repo-create always writes config/defaults, so a missing object was removed: the commands that use
340+
# the defaults refuse to run, unless every default is given explicitly (see check for the repair).
341+
archiver = request.getfixturevalue(archivers)
342+
create_regular_file(archiver.input_path, "file1", size=1024 * 80)
343+
cmd(archiver, "repo-create", RK_ENCRYPTION, "--compression=zstd,5")
344+
with open_repository(archiver) as repository:
345+
repository.store_delete("config/defaults")
346+
for args in (("create", "test", "input"), ("repo-info",)):
347+
if archiver.FORK_DEFAULT:
348+
output = cmd(archiver, *args, exit_code=Repository.DefaultsMissing("x").exit_code)
349+
assert "borg check --repair" in output
350+
else:
351+
with pytest.raises(Repository.DefaultsMissing):
352+
cmd(archiver, *args)
353+
cmd(archiver, "create", "--compression=lz4", "--chunker-params=fixed,4096", "test", "input")
354+
assert stored_compression(archiver) == {(LZ4.ID, 255)}

0 commit comments

Comments
 (0)