Merge branch 'master' into move-to-src

This commit is contained in:
Thomas Waldmann 2016-05-21 19:06:01 +02:00
commit 3ce35f6843
36 changed files with 1480 additions and 440 deletions

1
.gitignore vendored
View File

@ -11,6 +11,7 @@ crypto.c
platform_darwin.c platform_darwin.c
platform_freebsd.c platform_freebsd.c
platform_linux.c platform_linux.c
platform_posix.c
*.egg-info *.egg-info
*.pyc *.pyc
*.pyo *.pyo

4
Vagrantfile vendored
View File

@ -202,8 +202,8 @@ def install_borg(boxname)
rm -f borg/{chunker,crypto,compress,hashindex,platform_linux}.c rm -f borg/{chunker,crypto,compress,hashindex,platform_linux}.c
rm -rf borg/__pycache__ borg/support/__pycache__ borg/testsuite/__pycache__ rm -rf borg/__pycache__ borg/support/__pycache__ borg/testsuite/__pycache__
pip install -r requirements.d/development.txt pip install -r requirements.d/development.txt
pip install -r requirements.d/fuse.txt # by using [fuse], setup.py can handle different fuse requirements:
pip install -e . pip install -e .[fuse]
EOF EOF
end end

39
conftest.py Normal file
View File

@ -0,0 +1,39 @@
import sys
# This is a hack to fix path problems because "borg" (the package) is in the source root.
# When importing the conftest an "import borg" can incorrectly import the borg from the
# source root instead of the one installed in the environment.
# The workaround is to remove entries pointing there from the path and check whether "borg"
# is still importable. If it is not, then it has not been installed in the environment
# and the entries are put back.
#
# TODO: After moving the package to src/: remove this.
original_path = list(sys.path)
for entry in original_path:
if entry == '' or entry.endswith('/borg'):
sys.path.remove(entry)
try:
import borg
except ImportError:
sys.path = original_path
from borg.logger import setup_logging
# Ensure that the loggers exist for all tests
setup_logging()
from borg.testsuite import has_lchflags, no_lchlfags_because, has_llfuse
from borg.testsuite.platform import fakeroot_detected
from borg import xattr
def pytest_report_header(config, startdir):
yesno = ['no', 'yes']
flags = 'Testing BSD-style flags: %s %s' % (yesno[has_lchflags], no_lchlfags_because)
fakeroot = 'fakeroot: %s (>=1.20.2: %s)' % (
yesno[fakeroot_detected()],
yesno[xattr.XATTR_FAKEROOT])
llfuse = 'Testing fuse: %s' % yesno[has_llfuse]
return '\n'.join((flags, llfuse, fakeroot))

View File

@ -2,31 +2,11 @@
API Documentation API Documentation
================= =================
.. automodule:: borg.platform .. automodule:: borg.archiver
:members: :members:
:undoc-members: :undoc-members:
.. automodule:: borg.shellpattern .. automodule:: borg.upgrader
:members:
:undoc-members:
.. automodule:: borg.locking
:members:
:undoc-members:
.. automodule:: borg.hash_sizes
:members:
:undoc-members:
.. automodule:: borg.logger
:members:
:undoc-members:
.. automodule:: borg.remote
:members:
:undoc-members:
.. automodule:: borg.fuse
:members: :members:
:undoc-members: :undoc-members:
@ -34,7 +14,23 @@ API Documentation
:members: :members:
:undoc-members: :undoc-members:
.. automodule:: borg.helpers .. automodule:: borg.fuse
:members:
:undoc-members:
.. automodule:: borg.platform
:members:
:undoc-members:
.. automodule:: borg.locking
:members:
:undoc-members:
.. automodule:: borg.shellpattern
:members:
:undoc-members:
.. automodule:: borg.repository
:members: :members:
:undoc-members: :undoc-members:
@ -42,15 +38,19 @@ API Documentation
:members: :members:
:undoc-members: :undoc-members:
.. automodule:: borg.remote
:members:
:undoc-members:
.. automodule:: borg.hash_sizes
:members:
:undoc-members:
.. automodule:: borg.xattr .. automodule:: borg.xattr
:members: :members:
:undoc-members: :undoc-members:
.. automodule:: borg.archiver .. automodule:: borg.helpers
:members:
:undoc-members:
.. automodule:: borg.repository
:members: :members:
:undoc-members: :undoc-members:
@ -62,7 +62,7 @@ API Documentation
:members: :members:
:undoc-members: :undoc-members:
.. automodule:: borg.upgrader .. automodule:: borg.logger
:members: :members:
:undoc-members: :undoc-members:
@ -70,15 +70,15 @@ API Documentation
:members: :members:
:undoc-members: :undoc-members:
.. automodule:: borg.compress
:members:
:undoc-members:
.. automodule:: borg.platform_linux .. automodule:: borg.platform_linux
:members: :members:
:undoc-members: :undoc-members:
.. automodule:: borg.crypto .. automodule:: borg.hashindex
:members:
:undoc-members:
.. automodule:: borg.compress
:members: :members:
:undoc-members: :undoc-members:
@ -86,10 +86,10 @@ API Documentation
:members: :members:
:undoc-members: :undoc-members:
.. automodule:: borg.platform_freebsd .. automodule:: borg.crypto
:members: :members:
:undoc-members: :undoc-members:
.. automodule:: borg.hashindex .. automodule:: borg.platform_freebsd
:members: :members:
:undoc-members: :undoc-members:

View File

@ -6,6 +6,10 @@ Version 1.1.0 (not released yet)
New features: New features:
- borg check: will not produce the "Checking segments" output unless
new --progress option is passed, #824.
- options that imply output (--show-rc, --show-version, --list, --stats,
--progress) don't need -v/--info to have that output displayed, #865
- borg recreate: re-create existing archives, #787 #686 #630 #70, also see - borg recreate: re-create existing archives, #787 #686 #630 #70, also see
#757, #770. #757, #770.
@ -34,7 +38,7 @@ New features:
- borg comment: add archive comments, #842 - borg comment: add archive comments, #842
- provide "borgfs" wrapper for borg mount, enables usage via fstab, #743 - provide "borgfs" wrapper for borg mount, enables usage via fstab, #743
- create: add 'x' status for excluded paths, #814 - create: add 'x' status for excluded paths, #814
- --show-version: shows/logs the borg version (use -v), #725 - --show-version: shows/logs the borg version, #725
- borg list/prune/delete: also output archive id, #731 - borg list/prune/delete: also output archive id, #731
Bug fixes: Bug fixes:
@ -70,22 +74,34 @@ Other changes:
- ChunkBuffer: add test for leaving partial chunk in buffer, fixes #945 - ChunkBuffer: add test for leaving partial chunk in buffer, fixes #945
Version 1.0.3 (not released yet) Version 1.0.3
-------------------------------- -------------
Bug fixes: Bug fixes:
- prune: ignore checkpoints, #997 - prune: avoid that checkpoints are kept and completed archives are deleted in
- prune: fix bad validator, #942 a prune run), #997
- fix capabilities extraction on Linux (set xattrs last, after chown()) - prune: fix commandline argument validation - some valid command lines were
considered invalid (annoying, but harmless), #942
- fix capabilities extraction on Linux (set xattrs last, after chown()), #1069
- repository: fix commit tags being seen in data
- when probing key files, do binary reads. avoids crash when non-borg binary
files are located in borg's key files directory.
- handle SIGTERM and make a clean exit - avoids orphan lock files.
- repository cache: don't cache large objects (avoid using lots of temp. disk
space), #1063
Other changes: Other changes:
- update readthedocs URLs, #991
- add missing docs for "borg break-lock", #992
- borg create help: add some words to about the archive name
- borg create help: document format tags, #894
- Vagrantfile: OS X: update osxfuse / install lzma package, #933 - Vagrantfile: OS X: update osxfuse / install lzma package, #933
- setup.py: add check for platform_darwin.c
- setup.py: on freebsd, use a llfuse release that builds ok
- docs / help:
- update readthedocs URLs, #991
- add missing docs for "borg break-lock", #992
- borg create help: add some words to about the archive name
- borg create help: document format tags, #894
Version 1.0.2 Version 1.0.2

View File

@ -27,8 +27,10 @@ errors, critical for critical errors/states).
When directly talking to the user (e.g. Y/N questions), do not use logging, When directly talking to the user (e.g. Y/N questions), do not use logging,
but directly output to stderr (not: stdout, it could be connected to a pipe). but directly output to stderr (not: stdout, it could be connected to a pipe).
To control the amount and kinds of messages output to stderr or emitted at To control the amount and kinds of messages output emitted at info level, use
info level, use flags like ``--stats`` or ``--list``. flags like ``--stats`` or ``--list``, then create a topic logger for messages
controlled by that flag. See ``_setup_implied_logging()`` in
``borg/archiver.py`` for the entry point to topic logging.
Building a development environment Building a development environment
---------------------------------- ----------------------------------

View File

@ -46,7 +46,7 @@ A step by step example
3. The next day create a new archive called *Tuesday*:: 3. The next day create a new archive called *Tuesday*::
$ borg create -v --stats /path/to/repo::Tuesday ~/src ~/Documents $ borg create --stats /path/to/repo::Tuesday ~/src ~/Documents
This backup will be a lot quicker and a lot smaller since only new never This backup will be a lot quicker and a lot smaller since only new never
before seen data is stored. The ``--stats`` option causes |project_name| to before seen data is stored. The ``--stats`` option causes |project_name| to
@ -93,9 +93,10 @@ A step by step example
.. Note:: .. Note::
Borg is quiet by default (it works on WARNING log level). Borg is quiet by default (it works on WARNING log level).
Add the ``-v`` (or ``--verbose`` or ``--info``) option to adjust the log You can use options like ``--progress`` or ``--list`` to get specific
level to INFO and also use options like ``--progress`` or ``--list`` to reports during command execution. You can also add the ``-v`` (or
get progress reporting during command execution. ``--verbose`` or ``--info``) option to adjust the log level to INFO to
get other informational messages.
Automating backups Automating backups
------------------ ------------------
@ -105,23 +106,27 @@ server. The script also uses the :ref:`borg_prune` subcommand to maintain a
certain number of old archives:: certain number of old archives::
#!/bin/sh #!/bin/sh
REPOSITORY=username@remoteserver.com:backup
# Backup all of /home and /var/www except a few # setting this, so the repo does not need to be given on the commandline:
# excluded directories export BORG_REPO=username@remoteserver.com:backup
borg create -v --stats \
$REPOSITORY::`hostname`-`date +%Y-%m-%d` \ # setting this, so you won't be asked for your passphrase - make sure the
/home \ # script has appropriate owner/group and mode, e.g. root.root 600:
/var/www \ export BORG_PASSPHRASE=mysecret
--exclude '/home/*/.cache' \
--exclude /home/Ben/Music/Justin\ Bieber \ # Backup most important stuff:
borg create --stats -C lz4 ::`hostname`-`date +%Y-%m-%d` \
/etc \
/home \
/var \
--exclude '/home/*/.cache' \
--exclude '*.pyc' --exclude '*.pyc'
# Use the `prune` subcommand to maintain 7 daily, 4 weekly and 6 monthly # Use the `prune` subcommand to maintain 7 daily, 4 weekly and 6 monthly
# archives of THIS machine. --prefix `hostname`- is very important to # archives of THIS machine. Using --prefix is very important to
# limit prune's operation to this machine's archives and not apply to # limit prune's operation to this machine's archives and not apply to
# other machine's archives also. # other machine's archives also.
borg prune -v $REPOSITORY --prefix `hostname`- \ borg prune -v --prefix `hostname`- \
--keep-daily=7 --keep-weekly=4 --keep-monthly=6 --keep-daily=7 --keep-weekly=4 --keep-monthly=6
.. backup_compression: .. backup_compression:

View File

@ -16,7 +16,8 @@ Type of log output
The log level of the builtin logging configuration defaults to WARNING. The log level of the builtin logging configuration defaults to WARNING.
This is because we want |project_name| to be mostly silent and only output This is because we want |project_name| to be mostly silent and only output
warnings, errors and critical messages. warnings, errors and critical messages, unless output has been requested
by supplying an option that implies output (eg, --list or --progress).
Log levels: DEBUG < INFO < WARNING < ERROR < CRITICAL Log levels: DEBUG < INFO < WARNING < ERROR < CRITICAL
@ -41,10 +42,6 @@ give different output on different log levels - it's just a possibility.
.. warning:: Options --critical and --error are provided for completeness, .. warning:: Options --critical and --error are provided for completeness,
their usage is not recommended as you might miss important information. their usage is not recommended as you might miss important information.
.. warning:: While some options (like ``--stats`` or ``--list``) will emit more
informational messages, you have to use INFO (or lower) log level to make
them show up in log output. Use ``-v`` or a logging configuration.
Return codes Return codes
~~~~~~~~~~~~ ~~~~~~~~~~~~
@ -170,6 +167,22 @@ Network:
In case you are interested in more details, please read the internals documentation. In case you are interested in more details, please read the internals documentation.
File systems
~~~~~~~~~~~~
We strongly recommend against using Borg (or any other database-like
software) on non-journaling file systems like FAT, since it is not
possible to assume any consistency in case of power failures (or a
sudden disconnect of an external drive or similar failures).
While Borg uses a data store that is resilient against these failures
when used on journaling file systems, it is not possible to guarantee
this with some hardware -- independent of the software used. We don't
know a list of affected hardware.
If you are suspicious whether your Borg repository is still consistent
and readable after one of the failures mentioned above occured, run
``borg check --verify-data`` to make sure it is consistent.
Units Units
~~~~~ ~~~~~
@ -220,46 +233,6 @@ Examples
# Remote repository (store the key your home dir) # Remote repository (store the key your home dir)
$ borg init --encryption=keyfile user@hostname:backup $ borg init --encryption=keyfile user@hostname:backup
Important notes about encryption:
It is not recommended to disable encryption. Repository encryption protects you
e.g. against the case that an attacker has access to your backup repository.
But be careful with the key / the passphrase:
If you want "passphrase-only" security, use the ``repokey`` mode. The key will
be stored inside the repository (in its "config" file). In above mentioned
attack scenario, the attacker will have the key (but not the passphrase).
If you want "passphrase and having-the-key" security, use the ``keyfile`` mode.
The key will be stored in your home directory (in ``.config/borg/keys``). In
the attack scenario, the attacker who has just access to your repo won't have
the key (and also not the passphrase).
Make a backup copy of the key file (``keyfile`` mode) or repo config file
(``repokey`` mode) and keep it at a safe place, so you still have the key in
case it gets corrupted or lost. Also keep the passphrase at a safe place.
The backup that is encrypted with that key won't help you with that, of course.
Make sure you use a good passphrase. Not too short, not too simple. The real
encryption / decryption key is encrypted with / locked by your passphrase.
If an attacker gets your key, he can't unlock and use it without knowing the
passphrase.
Be careful with special or non-ascii characters in your passphrase:
- |project_name| processes the passphrase as unicode (and encodes it as utf-8),
so it does not have problems dealing with even the strangest characters.
- BUT: that does not necessarily apply to your OS / VM / keyboard configuration.
So better use a long passphrase made from simple ascii chars than one that
includes non-ascii stuff or characters that are hard/impossible to enter on
a different keyboard layout.
You can change your passphrase for existing repos at any time, it won't affect
the encryption/decryption key or other secrets.
.. include:: usage/create.rst.inc .. include:: usage/create.rst.inc
Examples Examples
@ -269,8 +242,8 @@ Examples
# Backup ~/Documents into an archive named "my-documents" # Backup ~/Documents into an archive named "my-documents"
$ borg create /path/to/repo::my-documents ~/Documents $ borg create /path/to/repo::my-documents ~/Documents
# same, but verbosely list all files as we process them # same, but list all files as we process them
$ borg create -v --list /path/to/repo::my-documents ~/Documents $ borg create --list /path/to/repo::my-documents ~/Documents
# Backup ~/Documents and ~/src but exclude pyc files # Backup ~/Documents and ~/src but exclude pyc files
$ borg create /path/to/repo::my-files \ $ borg create /path/to/repo::my-files \
@ -325,7 +298,10 @@ Examples
$ borg extract /path/to/repo::my-files $ borg extract /path/to/repo::my-files
# Extract entire archive and list files while processing # Extract entire archive and list files while processing
$ borg extract -v --list /path/to/repo::my-files $ borg extract --list /path/to/repo::my-files
# Verify whether an archive could be successfully extracted, but do not write files to disk
$ borg extract --dry-run /path/to/repo::my-files
# Extract the "src" directory # Extract the "src" directory
$ borg extract /path/to/repo::my-files home/USERNAME/src $ borg extract /path/to/repo::my-files home/USERNAME/src
@ -501,7 +477,7 @@ Examples
Username: root Username: root
Time (start): Mon, 2016-02-15 19:36:29 Time (start): Mon, 2016-02-15 19:36:29
Time (end): Mon, 2016-02-15 19:39:26 Time (end): Mon, 2016-02-15 19:39:26
Command line: /usr/local/bin/borg create -v --list -C zlib,6 /path/to/repo::root-2016-02-15 / --one-file-system Command line: /usr/local/bin/borg create --list -C zlib,6 /path/to/repo::root-2016-02-15 / --one-file-system
Number of files: 38100 Number of files: 38100
Original size Compressed size Deduplicated size Original size Compressed size Deduplicated size
@ -676,7 +652,7 @@ Here are misc. notes about topics that are maybe not covered in enough detail in
Item flags Item flags
~~~~~~~~~~ ~~~~~~~~~~
``borg create -v --list`` outputs a verbose list of all files, directories and other ``borg create --list`` outputs a list of all files, directories and other
file system items it considered (no matter whether they had content changes file system items it considered (no matter whether they had content changes
or not). For each item, it prefixes a single-letter flag that indicates type or not). For each item, it prefixes a single-letter flag that indicates type
and/or status of the item. and/or status of the item.

View File

@ -74,6 +74,12 @@ This command creates a backup archive containing all files found while recursive
traversing all paths specified. The archive will consume almost no disk space for traversing all paths specified. The archive will consume almost no disk space for
files or parts of files that have already been stored in other archives. files or parts of files that have already been stored in other archives.
The archive name needs to be unique. It must not end in '.checkpoint' or
'.checkpoint.N' (with N being a number), because these names are used for
checkpoints and treated in special ways.
In the archive name, you may use the following format tags:
{now}, {utcnow}, {fqdn}, {hostname}, {user}, {pid}
To speed up pulling backups over sshfs and similar network file systems which do To speed up pulling backups over sshfs and similar network file systems which do
not provide correct inode information the --ignore-inode flag can be used. This not provide correct inode information the --ignore-inode flag can be used. This

View File

@ -26,20 +26,26 @@ install_requires = ['msgpack-python>=0.4.6', ]
extras_require = { extras_require = {
# llfuse 0.40 (tested, proven, ok), needs FUSE version >= 2.8.0 # llfuse 0.40 (tested, proven, ok), needs FUSE version >= 2.8.0
# llfuse 0.41 (tested shortly, looks ok), needs FUSE version >= 2.8.0 # llfuse 0.41 (tested shortly, looks ok), needs FUSE version >= 2.8.0
# llfuse 0.41.1 (tested shortly, looks ok), needs FUSE version >= 2.8.0
# llfuse 0.42 (tested shortly, looks ok), needs FUSE version >= 2.8.0 # llfuse 0.42 (tested shortly, looks ok), needs FUSE version >= 2.8.0
# llfuse 1.0 (tested shortly, looks ok), needs FUSE version >= 2.8.0 # llfuse 1.0 (tested shortly, looks ok), needs FUSE version >= 2.8.0
# llfuse 2.0 will break API # llfuse 2.0 will break API
'fuse': ['llfuse<2.0', ], 'fuse': ['llfuse<2.0', ],
} }
if sys.platform.startswith('freebsd'):
# while llfuse 1.0 is the latest llfuse release right now,
# llfuse 0.41.1 is the latest release that actually builds on freebsd:
extras_require['fuse'] = ['llfuse==0.41.1', ]
from setuptools import setup, find_packages, Extension from setuptools import setup, find_packages, Extension
from setuptools.command.sdist import sdist from setuptools.command.sdist import sdist
compress_source = 'src/borg/compress.pyx' compress_source = 'src/borg/compress.pyx'
crypto_source = 'src/borg/crypto.pyx' crypto_source = 'src/borg/crypto.pyx'
chunker_source = 'src/borg/chunker.pyx' chunker_source = 'src/borg/chunker.pyx'
hashindex_source = 'src/borg/hashindex.pyx' hashindex_source = 'src/borg/hashindex.pyx'
platform_posix_source = 'src/borg/platform_posix.pyx'
platform_linux_source = 'src/borg/platform_linux.pyx' platform_linux_source = 'src/borg/platform_linux.pyx'
platform_darwin_source = 'src/borg/platform_darwin.pyx' platform_darwin_source = 'src/borg/platform_darwin.pyx'
platform_freebsd_source = 'src/borg/platform_freebsd.pyx' platform_freebsd_source = 'src/borg/platform_freebsd.pyx'
@ -60,6 +66,7 @@ try:
'src/borg/crypto.c', 'src/borg/crypto.c',
'src/borg/chunker.c', 'src/borg/_chunker.c', 'src/borg/chunker.c', 'src/borg/_chunker.c',
'src/borg/hashindex.c', 'src/borg/_hashindex.c', 'src/borg/hashindex.c', 'src/borg/_hashindex.c',
'src/borg/platform_posix.c',
'src/borg/platform_linux.c', 'src/borg/platform_linux.c',
'src/borg/platform_freebsd.c', 'src/borg/platform_freebsd.c',
'src/borg/platform_darwin.c', 'src/borg/platform_darwin.c',
@ -75,13 +82,14 @@ except ImportError:
crypto_source = crypto_source.replace('.pyx', '.c') crypto_source = crypto_source.replace('.pyx', '.c')
chunker_source = chunker_source.replace('.pyx', '.c') chunker_source = chunker_source.replace('.pyx', '.c')
hashindex_source = hashindex_source.replace('.pyx', '.c') hashindex_source = hashindex_source.replace('.pyx', '.c')
platform_posix_source = platform_posix_source.replace('.pyx', '.c')
platform_linux_source = platform_linux_source.replace('.pyx', '.c') platform_linux_source = platform_linux_source.replace('.pyx', '.c')
platform_freebsd_source = platform_freebsd_source.replace('.pyx', '.c') platform_freebsd_source = platform_freebsd_source.replace('.pyx', '.c')
platform_darwin_source = platform_darwin_source.replace('.pyx', '.c') platform_darwin_source = platform_darwin_source.replace('.pyx', '.c')
from distutils.command.build_ext import build_ext from distutils.command.build_ext import build_ext
if not on_rtd and not all(os.path.exists(path) for path in [ if not on_rtd and not all(os.path.exists(path) for path in [
compress_source, crypto_source, chunker_source, hashindex_source, compress_source, crypto_source, chunker_source, hashindex_source,
platform_linux_source, platform_freebsd_source]): platform_posix_source, platform_linux_source, platform_freebsd_source, platform_darwin_source]):
raise ImportError('The GIT version of Borg needs Cython. Install Cython or use a released version.') raise ImportError('The GIT version of Borg needs Cython. Install Cython or use a released version.')
@ -106,7 +114,8 @@ def detect_lz4(prefixes):
include_dirs = [] include_dirs = []
library_dirs = [] library_dirs = []
possible_openssl_prefixes = ['/usr', '/usr/local', '/usr/local/opt/openssl', '/usr/local/ssl', '/usr/local/openssl', '/usr/local/borg', '/opt/local'] possible_openssl_prefixes = ['/usr', '/usr/local', '/usr/local/opt/openssl', '/usr/local/ssl', '/usr/local/openssl',
'/usr/local/borg', '/opt/local', '/opt/pkg', ]
if os.environ.get('BORG_OPENSSL_PREFIX'): if os.environ.get('BORG_OPENSSL_PREFIX'):
possible_openssl_prefixes.insert(0, os.environ.get('BORG_OPENSSL_PREFIX')) possible_openssl_prefixes.insert(0, os.environ.get('BORG_OPENSSL_PREFIX'))
ssl_prefix = detect_openssl(possible_openssl_prefixes) ssl_prefix = detect_openssl(possible_openssl_prefixes)
@ -116,7 +125,8 @@ include_dirs.append(os.path.join(ssl_prefix, 'include'))
library_dirs.append(os.path.join(ssl_prefix, 'lib')) library_dirs.append(os.path.join(ssl_prefix, 'lib'))
possible_lz4_prefixes = ['/usr', '/usr/local', '/usr/local/opt/lz4', '/usr/local/lz4', '/usr/local/borg', '/opt/local'] possible_lz4_prefixes = ['/usr', '/usr/local', '/usr/local/opt/lz4', '/usr/local/lz4',
'/usr/local/borg', '/opt/local', '/opt/pkg', ]
if os.environ.get('BORG_LZ4_PREFIX'): if os.environ.get('BORG_LZ4_PREFIX'):
possible_lz4_prefixes.insert(0, os.environ.get('BORG_LZ4_PREFIX')) possible_lz4_prefixes.insert(0, os.environ.get('BORG_LZ4_PREFIX'))
lz4_prefix = detect_lz4(possible_lz4_prefixes) lz4_prefix = detect_lz4(possible_lz4_prefixes)
@ -284,6 +294,9 @@ if not on_rtd:
Extension('borg.chunker', [chunker_source]), Extension('borg.chunker', [chunker_source]),
Extension('borg.hashindex', [hashindex_source]) Extension('borg.hashindex', [hashindex_source])
] ]
if sys.platform.startswith(('linux', 'freebsd', 'darwin')):
ext_modules.append(Extension('borg.platform_posix', [platform_posix_source]))
if sys.platform == 'linux': if sys.platform == 'linux':
ext_modules.append(Extension('borg.platform_linux', [platform_linux_source], libraries=['acl'])) ext_modules.append(Extension('borg.platform_linux', [platform_linux_source], libraries=['acl']))
elif sys.platform.startswith('freebsd'): elif sys.platform.startswith('freebsd'):

View File

@ -184,9 +184,9 @@ chunker_fill(Chunker *c)
length = c->bytes_read - offset; length = c->bytes_read - offset;
#if ( ( _XOPEN_SOURCE >= 600 || _POSIX_C_SOURCE >= 200112L ) && defined(POSIX_FADV_DONTNEED) ) #if ( ( _XOPEN_SOURCE >= 600 || _POSIX_C_SOURCE >= 200112L ) && defined(POSIX_FADV_DONTNEED) )
// Only do it once per run. // Only do it once per run.
if (pagemask == 0) if (pagemask == 0)
pagemask = getpagesize() - 1; pagemask = getpagesize() - 1;
// We tell the OS that we do not need the data that we just have read any // We tell the OS that we do not need the data that we just have read any
// more (that it maybe has in the cache). This avoids that we spoil the // more (that it maybe has in the cache). This avoids that we spoil the
@ -207,7 +207,7 @@ chunker_fill(Chunker *c)
// fadvise. This will cancel the final page and is not part // fadvise. This will cancel the final page and is not part
// of the above workaround. // of the above workaround.
overshoot = 0; overshoot = 0;
} }
posix_fadvise(c->fh, offset & ~pagemask, length - overshoot, POSIX_FADV_DONTNEED); posix_fadvise(c->fh, offset & ~pagemask, length - overshoot, POSIX_FADV_DONTNEED);
#endif #endif

View File

@ -9,6 +9,7 @@ from .key import key_factory
from .remote import cache_if_remote from .remote import cache_if_remote
import os import os
from shutil import get_terminal_size
import socket import socket
import stat import stat
import sys import sys
@ -19,24 +20,76 @@ from .compress import COMPR_BUFFER
from .constants import * # NOQA from .constants import * # NOQA
from .helpers import Chunk, Error, uid2user, user2uid, gid2group, group2gid, \ from .helpers import Chunk, Error, uid2user, user2uid, gid2group, group2gid, \
parse_timestamp, to_localtime, format_time, format_timedelta, safe_encode, safe_decode, \ parse_timestamp, to_localtime, format_time, format_timedelta, safe_encode, safe_decode, \
Manifest, Statistics, decode_dict, make_path_safe, StableDict, int_to_bigint, bigint_to_int, bin_to_hex, \ Manifest, decode_dict, make_path_safe, StableDict, int_to_bigint, bigint_to_int, bin_to_hex, \
ProgressIndicatorPercent, ChunkIteratorFileWrapper, remove_surrogates, log_multi, \ ProgressIndicatorPercent, ChunkIteratorFileWrapper, remove_surrogates, log_multi, \
PathPrefixPattern, FnmatchPattern, open_item, file_status, format_file_size, consume, \ PathPrefixPattern, FnmatchPattern, open_item, file_status, format_file_size, consume, \
CompressionDecider1, CompressionDecider2, CompressionSpec CompressionDecider1, CompressionDecider2, CompressionSpec, \
IntegrityError
from .repository import Repository from .repository import Repository
from .platform import acl_get, acl_set from .platform import acl_get, acl_set, set_flags, get_flags, swidth
from .chunker import Chunker from .chunker import Chunker
from .hashindex import ChunkIndex, ChunkIndexEntry from .hashindex import ChunkIndex, ChunkIndexEntry
from .cache import ChunkListEntry from .cache import ChunkListEntry
import msgpack import msgpack
has_lchmod = hasattr(os, 'lchmod') has_lchmod = hasattr(os, 'lchmod')
has_lchflags = hasattr(os, 'lchflags')
flags_normal = os.O_RDONLY | getattr(os, 'O_BINARY', 0) flags_normal = os.O_RDONLY | getattr(os, 'O_BINARY', 0)
flags_noatime = flags_normal | getattr(os, 'O_NOATIME', 0) flags_noatime = flags_normal | getattr(os, 'O_NOATIME', 0)
class Statistics:
def __init__(self):
self.osize = self.csize = self.usize = self.nfiles = 0
self.last_progress = 0 # timestamp when last progress was shown
def update(self, size, csize, unique):
self.osize += size
self.csize += csize
if unique:
self.usize += csize
summary = """\
Original size Compressed size Deduplicated size
{label:15} {stats.osize_fmt:>20s} {stats.csize_fmt:>20s} {stats.usize_fmt:>20s}"""
def __str__(self):
return self.summary.format(stats=self, label='This archive:')
def __repr__(self):
return "<{cls} object at {hash:#x} ({self.osize}, {self.csize}, {self.usize})>".format(
cls=type(self).__name__, hash=id(self), self=self)
@property
def osize_fmt(self):
return format_file_size(self.osize)
@property
def usize_fmt(self):
return format_file_size(self.usize)
@property
def csize_fmt(self):
return format_file_size(self.csize)
def show_progress(self, item=None, final=False, stream=None, dt=None):
now = time.time()
if dt is None or now - self.last_progress > dt:
self.last_progress = now
columns, lines = get_terminal_size()
if not final:
msg = '{0.osize_fmt} O {0.csize_fmt} C {0.usize_fmt} D {0.nfiles} N '.format(self)
path = remove_surrogates(item[b'path']) if item else ''
space = columns - swidth(msg)
if space < swidth('...') + swidth(path):
path = '%s...%s' % (path[:(space // 2) - swidth('...')], path[-space // 2:])
msg += "{0:<{space}}".format(path, space=space)
else:
msg = ' ' * columns
print(msg, file=stream or sys.stderr, end="\r", flush=True)
class DownloadPipeline: class DownloadPipeline:
def __init__(self, repository, key): def __init__(self, repository, key):
@ -434,10 +487,9 @@ Number of files: {0.stats.nfiles}'''.format(
else: else:
os.utime(path, None, ns=(atime, mtime), follow_symlinks=False) os.utime(path, None, ns=(atime, mtime), follow_symlinks=False)
acl_set(path, item, self.numeric_owner) acl_set(path, item, self.numeric_owner)
# Only available on OS X and FreeBSD if b'bsdflags' in item:
if has_lchflags and b'bsdflags' in item:
try: try:
os.lchflags(path, item[b'bsdflags']) set_flags(path, item[b'bsdflags'], fd=fd)
except OSError: except OSError:
pass pass
# chown removes Linux capabilities, so set the extended attributes at the end, after chown, since they include # chown removes Linux capabilities, so set the extended attributes at the end, after chown, since they include
@ -505,8 +557,9 @@ Number of files: {0.stats.nfiles}'''.format(
xattrs = xattr.get_all(path, follow_symlinks=False) xattrs = xattr.get_all(path, follow_symlinks=False)
if xattrs: if xattrs:
item[b'xattrs'] = StableDict(xattrs) item[b'xattrs'] = StableDict(xattrs)
if has_lchflags and st.st_flags: bsdflags = get_flags(path, st)
item[b'bsdflags'] = st.st_flags if bsdflags:
item[b'bsdflags'] = bsdflags
acl_get(path, item, st, self.numeric_owner) acl_get(path, item, st, self.numeric_owner)
return item return item
@ -698,7 +751,17 @@ class ArchiveChecker:
self.error_found = False self.error_found = False
self.possibly_superseded = set() self.possibly_superseded = set()
def check(self, repository, repair=False, archive=None, last=None, prefix=None, save_space=False): def check(self, repository, repair=False, archive=None, last=None, prefix=None, verify_data=False,
save_space=False):
"""Perform a set of checks on 'repository'
:param repair: enable repair mode, write updated or corrected data into repository
:param archive: only check this archive
:param last: only check this number of recent archives
:param prefix: only check archives with this prefix
:param verify_data: integrity verification of data referenced by archives
:param save_space: Repository.commit(save_space)
"""
logger.info('Starting archive consistency check...') logger.info('Starting archive consistency check...')
self.check_all = archive is None and last is None and prefix is None self.check_all = archive is None and last is None and prefix is None
self.repair = repair self.repair = repair
@ -712,6 +775,8 @@ class ArchiveChecker:
else: else:
self.manifest, _ = Manifest.load(repository, key=self.key) self.manifest, _ = Manifest.load(repository, key=self.key)
self.rebuild_refcounts(archive=archive, last=last, prefix=prefix) self.rebuild_refcounts(archive=archive, last=last, prefix=prefix)
if verify_data:
self.verify_data()
self.orphan_chunks_check() self.orphan_chunks_check()
self.finish(save_space=save_space) self.finish(save_space=save_space)
if self.error_found: if self.error_found:
@ -741,6 +806,26 @@ class ArchiveChecker:
cdata = repository.get(next(self.chunks.iteritems())[0]) cdata = repository.get(next(self.chunks.iteritems())[0])
return key_factory(repository, cdata) return key_factory(repository, cdata)
def verify_data(self):
logger.info('Starting cryptographic data integrity verification...')
pi = ProgressIndicatorPercent(total=len(self.chunks), msg="Verifying data %6.2f%%", step=0.01, same_line=True)
count = errors = 0
for chunk_id, (refcount, *_) in self.chunks.iteritems():
pi.show()
if not refcount:
continue
encrypted_data = self.repository.get(chunk_id)
try:
_, data = self.key.decrypt(chunk_id, encrypted_data)
except IntegrityError as integrity_error:
self.error_found = True
errors += 1
logger.error('chunk %s, integrity error: %s', bin_to_hex(chunk_id), integrity_error)
count += 1
pi.finish()
log = logger.error if errors else logger.info
log('Finished cryptographic data integrity verification, verified %d chunks with %d integrity errors.', count, errors)
def rebuild_manifest(self): def rebuild_manifest(self):
"""Rebuild the manifest object if it is missing """Rebuild the manifest object if it is missing
@ -874,6 +959,8 @@ class ArchiveChecker:
else: else:
# we only want one specific archive # we only want one specific archive
archive_items = [item for item in self.manifest.archives.items() if item[0] == archive] archive_items = [item for item in self.manifest.archives.items() if item[0] == archive]
if not archive_items:
logger.error("Archive '%s' not found.", archive)
num_archives = 1 num_archives = 1
end = 1 end = 1

View File

@ -8,6 +8,7 @@ import functools
import hashlib import hashlib
import inspect import inspect
import io import io
import logging
import os import os
import re import re
import shlex import shlex
@ -22,7 +23,7 @@ from . import __version__
from .helpers import Error, location_validator, archivename_validator, format_time, format_file_size, \ from .helpers import Error, location_validator, archivename_validator, format_time, format_file_size, \
parse_pattern, PathPrefixPattern, to_localtime, timestamp, \ parse_pattern, PathPrefixPattern, to_localtime, timestamp, \
get_cache_dir, prune_within, prune_split, bin_to_hex, safe_encode, \ get_cache_dir, prune_within, prune_split, bin_to_hex, safe_encode, \
Manifest, remove_surrogates, update_excludes, format_archive, check_extension_modules, Statistics, \ Manifest, remove_surrogates, update_excludes, format_archive, check_extension_modules, \
dir_is_tagged, ChunkerParams, CompressionSpec, is_slow_msgpack, yes, sysinfo, \ dir_is_tagged, ChunkerParams, CompressionSpec, is_slow_msgpack, yes, sysinfo, \
log_multi, PatternMatcher, ItemFormatter log_multi, PatternMatcher, ItemFormatter
from .logger import create_logger, setup_logging from .logger import create_logger, setup_logging
@ -34,12 +35,11 @@ from .repository import Repository
from .cache import Cache from .cache import Cache
from .constants import * # NOQA from .constants import * # NOQA
from .key import key_creator, RepoKey, PassphraseKey from .key import key_creator, RepoKey, PassphraseKey
from .archive import Archive, ArchiveChecker, ArchiveRecreater from .archive import Archive, ArchiveChecker, ArchiveRecreater, Statistics
from .remote import RepositoryServer, RemoteRepository, cache_if_remote from .remote import RepositoryServer, RemoteRepository, cache_if_remote
from .selftest import selftest from .selftest import selftest
from .hashindex import ChunkIndexEntry from .hashindex import ChunkIndexEntry
from .platform import get_flags
has_lchflags = hasattr(os, 'lchflags')
def argument(args, str_or_bool): def argument(args, str_or_bool):
@ -112,7 +112,7 @@ class Archiver:
def print_file_status(self, status, path): def print_file_status(self, status, path):
if self.output_list and (self.output_filter is None or status in self.output_filter): if self.output_list and (self.output_filter is None or status in self.output_filter):
logger.info("%1s %s", status, remove_surrogates(path)) logging.getLogger('borg.output.list').info("%1s %s", status, remove_surrogates(path))
@staticmethod @staticmethod
def compare_chunk_contents(chunks1, chunks2): def compare_chunk_contents(chunks1, chunks2):
@ -185,12 +185,16 @@ class Archiver:
if not yes(msg, false_msg="Aborting.", truish=('YES', ), if not yes(msg, false_msg="Aborting.", truish=('YES', ),
env_var_override='BORG_CHECK_I_KNOW_WHAT_I_AM_DOING'): env_var_override='BORG_CHECK_I_KNOW_WHAT_I_AM_DOING'):
return EXIT_ERROR return EXIT_ERROR
if args.repo_only and args.verify_data:
self.print_error("--repository-only and --verify-data contradict each other. Please select one.")
return EXIT_ERROR
if not args.archives_only: if not args.archives_only:
if not repository.check(repair=args.repair, save_space=args.save_space): if not repository.check(repair=args.repair, save_space=args.save_space):
return EXIT_WARNING return EXIT_WARNING
if not args.repo_only and not ArchiveChecker().check( if not args.repo_only and not ArchiveChecker().check(
repository, repair=args.repair, archive=args.location.archive, repository, repair=args.repair, archive=args.location.archive,
last=args.last, prefix=args.prefix, save_space=args.save_space): last=args.last, prefix=args.prefix, verify_data=args.verify_data,
save_space=args.save_space):
return EXIT_WARNING return EXIT_WARNING
return EXIT_SUCCESS return EXIT_SUCCESS
@ -274,7 +278,7 @@ class Archiver:
DASHES, DASHES,
str(archive.stats), str(archive.stats),
str(cache), str(cache),
DASHES) DASHES, logger=logging.getLogger('borg.output.stats'))
self.output_filter = args.output_filter self.output_filter = args.output_filter
self.output_list = args.output_list self.output_list = args.output_list
@ -312,7 +316,7 @@ class Archiver:
return return
status = None status = None
# Ignore if nodump flag is set # Ignore if nodump flag is set
if has_lchflags and (st.st_flags & stat.UF_NODUMP): if get_flags(path, st) & stat.UF_NODUMP:
return return
if stat.S_ISREG(st.st_mode) or read_special and not stat.S_ISDIR(st.st_mode): if stat.S_ISREG(st.st_mode) or read_special and not stat.S_ISDIR(st.st_mode):
if not dry_run: if not dry_run:
@ -413,7 +417,7 @@ class Archiver:
while dirs and not item[b'path'].startswith(dirs[-1][b'path']): while dirs and not item[b'path'].startswith(dirs[-1][b'path']):
archive.extract_item(dirs.pop(-1), stdout=stdout) archive.extract_item(dirs.pop(-1), stdout=stdout)
if output_list: if output_list:
logger.info(remove_surrogates(orig_path)) logging.getLogger('borg.output.list').info(remove_surrogates(orig_path))
try: try:
if dry_run: if dry_run:
archive.extract_item(item, dry_run=True) archive.extract_item(item, dry_run=True)
@ -670,7 +674,7 @@ class Archiver:
log_multi(DASHES, log_multi(DASHES,
stats.summary.format(label='Deleted data:', stats=stats), stats.summary.format(label='Deleted data:', stats=stats),
str(cache), str(cache),
DASHES) DASHES, logger=logging.getLogger('borg.output.stats'))
else: else:
if not args.cache_only: if not args.cache_only:
msg = [] msg = []
@ -789,9 +793,8 @@ class Archiver:
is_checkpoint = re.compile(r'\.checkpoint(\.\d+)?$').search is_checkpoint = re.compile(r'\.checkpoint(\.\d+)?$').search
checkpoints = [arch for arch in archives_checkpoints if is_checkpoint(arch.name)] checkpoints = [arch for arch in archives_checkpoints if is_checkpoint(arch.name)]
# keep the latest checkpoint, if there is no later non-checkpoint archive # keep the latest checkpoint, if there is no later non-checkpoint archive
latest_checkpoint = checkpoints[0] if checkpoints else None if archives_checkpoints and checkpoints and archives_checkpoints[0] is checkpoints[0]:
if archives_checkpoints[0] is latest_checkpoint: keep_checkpoints = checkpoints[:1]
keep_checkpoints = [latest_checkpoint, ]
else: else:
keep_checkpoints = [] keep_checkpoints = []
checkpoints = set(checkpoints) checkpoints = set(checkpoints)
@ -818,18 +821,19 @@ class Archiver:
to_delete = (set(archives) | checkpoints) - (set(keep) | set(keep_checkpoints)) to_delete = (set(archives) | checkpoints) - (set(keep) | set(keep_checkpoints))
stats = Statistics() stats = Statistics()
with Cache(repository, key, manifest, do_files=args.cache_files, lock_wait=self.lock_wait) as cache: with Cache(repository, key, manifest, do_files=args.cache_files, lock_wait=self.lock_wait) as cache:
list_logger = logging.getLogger('borg.output.list')
for archive in archives_checkpoints: for archive in archives_checkpoints:
if archive in to_delete: if archive in to_delete:
if args.dry_run: if args.dry_run:
if args.output_list: if args.output_list:
logger.info('Would prune: %s' % format_archive(archive)) list_logger.info('Would prune: %s' % format_archive(archive))
else: else:
if args.output_list: if args.output_list:
logger.info('Pruning archive: %s' % format_archive(archive)) list_logger.info('Pruning archive: %s' % format_archive(archive))
Archive(repository, key, manifest, archive.name, cache).delete(stats) Archive(repository, key, manifest, archive.name, cache).delete(stats)
else: else:
if args.output_list: if args.output_list:
logger.info('Keeping archive: %s' % format_archive(archive)) list_logger.info('Keeping archive: %s' % format_archive(archive))
if to_delete and not args.dry_run: if to_delete and not args.dry_run:
manifest.write() manifest.write()
repository.commit(save_space=args.save_space) repository.commit(save_space=args.save_space)
@ -838,7 +842,7 @@ class Archiver:
log_multi(DASHES, log_multi(DASHES,
stats.summary.format(label='Deleted data:', stats=stats), stats.summary.format(label='Deleted data:', stats=stats),
str(cache), str(cache),
DASHES) DASHES, logger=logging.getLogger('borg.output.stats'))
return self.exit_code return self.exit_code
def do_upgrade(self, args): def do_upgrade(self, args):
@ -1164,7 +1168,48 @@ class Archiver:
init_epilog = textwrap.dedent(""" init_epilog = textwrap.dedent("""
This command initializes an empty repository. A repository is a filesystem This command initializes an empty repository. A repository is a filesystem
directory containing the deduplicated data from zero or more archives. directory containing the deduplicated data from zero or more archives.
Encryption can be enabled at repository init time.
Encryption can be enabled at repository init time (the default).
It is not recommended to disable encryption. Repository encryption protects you
e.g. against the case that an attacker has access to your backup repository.
But be careful with the key / the passphrase:
If you want "passphrase-only" security, use the repokey mode. The key will
be stored inside the repository (in its "config" file). In above mentioned
attack scenario, the attacker will have the key (but not the passphrase).
If you want "passphrase and having-the-key" security, use the keyfile mode.
The key will be stored in your home directory (in .config/borg/keys). In
the attack scenario, the attacker who has just access to your repo won't have
the key (and also not the passphrase).
Make a backup copy of the key file (keyfile mode) or repo config file
(repokey mode) and keep it at a safe place, so you still have the key in
case it gets corrupted or lost. Also keep the passphrase at a safe place.
The backup that is encrypted with that key won't help you with that, of course.
Make sure you use a good passphrase. Not too short, not too simple. The real
encryption / decryption key is encrypted with / locked by your passphrase.
If an attacker gets your key, he can't unlock and use it without knowing the
passphrase.
Be careful with special or non-ascii characters in your passphrase:
- Borg processes the passphrase as unicode (and encodes it as utf-8),
so it does not have problems dealing with even the strangest characters.
- BUT: that does not necessarily apply to your OS / VM / keyboard configuration.
So better use a long passphrase made from simple ascii chars than one that
includes non-ascii stuff or characters that are hard/impossible to enter on
a different keyboard layout.
You can change your passphrase for existing repos at any time, it won't affect
the encryption/decryption key or other secrets.
When encrypting, AES-CTR-256 is used for encryption, and HMAC-SHA256 for
authentication. Hardware acceleration will be used automatically.
""") """)
subparser = subparsers.add_parser('init', parents=[common_parser], add_help=False, subparser = subparsers.add_parser('init', parents=[common_parser], add_help=False,
description=self.do_init.__doc__, epilog=init_epilog, description=self.do_init.__doc__, epilog=init_epilog,
@ -1213,6 +1258,18 @@ class Archiver:
required). required).
- The archive checks can be time consuming, they can be skipped using the - The archive checks can be time consuming, they can be skipped using the
--repository-only option. --repository-only option.
The --verify-data option will perform a full integrity verification (as opposed to
checking the CRC32 of the segment) of data, which means reading the data from the
repository, decrypting and decompressing it. This is a cryptographic verification,
which will detect (accidental) corruption. For encrypted repositories it is
tamper-resistant as well, unless the attacker has access to the keys.
It is also very slow.
--verify-data only verifies data used by the archives specified with --last,
--prefix or an explicitly named archive. If none of these are passed,
all data in the repository is verified.
""") """)
subparser = subparsers.add_parser('check', parents=[common_parser], add_help=False, subparser = subparsers.add_parser('check', parents=[common_parser], add_help=False,
description=self.do_check.__doc__, description=self.do_check.__doc__,
@ -1229,6 +1286,10 @@ class Archiver:
subparser.add_argument('--archives-only', dest='archives_only', action='store_true', subparser.add_argument('--archives-only', dest='archives_only', action='store_true',
default=False, default=False,
help='only perform archives checks') help='only perform archives checks')
subparser.add_argument('--verify-data', dest='verify_data', action='store_true',
default=False,
help='perform cryptographic archive data integrity verification '
'(conflicts with --repository-only)')
subparser.add_argument('--repair', dest='repair', action='store_true', subparser.add_argument('--repair', dest='repair', action='store_true',
default=False, default=False,
help='attempt to repair any inconsistencies found') help='attempt to repair any inconsistencies found')
@ -1240,6 +1301,9 @@ class Archiver:
help='only check last N archives (Default: all)') help='only check last N archives (Default: all)')
subparser.add_argument('-P', '--prefix', dest='prefix', type=str, subparser.add_argument('-P', '--prefix', dest='prefix', type=str,
help='only consider archive names starting with this prefix') help='only consider archive names starting with this prefix')
subparser.add_argument('-p', '--progress', dest='progress',
action='store_true', default=False,
help="""show progress display while checking""")
change_passphrase_epilog = textwrap.dedent(""" change_passphrase_epilog = textwrap.dedent("""
The key files used for repository encryption are optionally passphrase The key files used for repository encryption are optionally passphrase
@ -1401,6 +1465,10 @@ class Archiver:
be restricted by using the ``--exclude`` option. be restricted by using the ``--exclude`` option.
See the output of the "borg help patterns" command for more help on exclude patterns. See the output of the "borg help patterns" command for more help on exclude patterns.
By using ``--dry-run``, you can do all extraction steps except actually writing the
output data: reading metadata and data chunks from the repo, checking the hash/hmac,
decrypting, decompressing.
""") """)
subparser = subparsers.add_parser('extract', parents=[common_parser], add_help=False, subparser = subparsers.add_parser('extract', parents=[common_parser], add_help=False,
description=self.do_extract.__doc__, description=self.do_extract.__doc__,
@ -2002,12 +2070,27 @@ class Archiver:
check_extension_modules() check_extension_modules()
selftest(logger) selftest(logger)
def _setup_implied_logging(self, args):
""" turn on INFO level logging for args that imply that they will produce output """
# map of option name to name of logger for that option
option_logger = {
'output_list': 'borg.output.list',
'show_version': 'borg.output.show-version',
'show_rc': 'borg.output.show-rc',
'stats': 'borg.output.stats',
'progress': 'borg.output.progress',
}
for option, logger_name in option_logger.items():
if args.get(option, False):
logging.getLogger(logger_name).setLevel('INFO')
def run(self, args): def run(self, args):
os.umask(args.umask) # early, before opening files os.umask(args.umask) # early, before opening files
self.lock_wait = args.lock_wait self.lock_wait = args.lock_wait
setup_logging(level=args.log_level, is_serve=args.func == self.do_serve) # do not use loggers before this! setup_logging(level=args.log_level, is_serve=args.func == self.do_serve) # do not use loggers before this!
self._setup_implied_logging(vars(args))
if args.show_version: if args.show_version:
logger.info('borgbackup version %s' % __version__) logging.getLogger('borg.output.show-version').info('borgbackup version %s' % __version__)
self.prerun_checks(logger) self.prerun_checks(logger)
if is_slow_msgpack(): if is_slow_msgpack():
logger.warning("Using a pure-python msgpack! This will result in lower performance.") logger.warning("Using a pure-python msgpack! This will result in lower performance.")
@ -2037,6 +2120,14 @@ def sig_info_handler(signum, stack): # pragma: no cover
break break
class SIGTERMReceived(BaseException):
pass
def sig_term_handler(signum, stack):
raise SIGTERMReceived
def setup_signal_handlers(): # pragma: no cover def setup_signal_handlers(): # pragma: no cover
sigs = [] sigs = []
if hasattr(signal, 'SIGUSR1'): if hasattr(signal, 'SIGUSR1'):
@ -2045,6 +2136,7 @@ def setup_signal_handlers(): # pragma: no cover
sigs.append(signal.SIGINFO) # kill -INFO pid (or ctrl-t) sigs.append(signal.SIGINFO) # kill -INFO pid (or ctrl-t)
for sig in sigs: for sig in sigs:
signal.signal(sig, sig_info_handler) signal.signal(sig, sig_info_handler)
signal.signal(signal.SIGTERM, sig_term_handler)
def main(): # pragma: no cover def main(): # pragma: no cover
@ -2076,18 +2168,22 @@ def main(): # pragma: no cover
except KeyboardInterrupt: except KeyboardInterrupt:
msg = 'Keyboard interrupt.\n%s\n%s' % (traceback.format_exc(), sysinfo()) msg = 'Keyboard interrupt.\n%s\n%s' % (traceback.format_exc(), sysinfo())
exit_code = EXIT_ERROR exit_code = EXIT_ERROR
except SIGTERMReceived:
msg = 'Received SIGTERM.'
exit_code = EXIT_ERROR
if msg: if msg:
logger.error(msg) logger.error(msg)
if args.show_rc: if args.show_rc:
rc_logger = logging.getLogger('borg.output.show-rc')
exit_msg = 'terminating with %s status, rc %d' exit_msg = 'terminating with %s status, rc %d'
if exit_code == EXIT_SUCCESS: if exit_code == EXIT_SUCCESS:
logger.info(exit_msg % ('success', exit_code)) rc_logger.info(exit_msg % ('success', exit_code))
elif exit_code == EXIT_WARNING: elif exit_code == EXIT_WARNING:
logger.warning(exit_msg % ('warning', exit_code)) rc_logger.warning(exit_msg % ('warning', exit_code))
elif exit_code == EXIT_ERROR: elif exit_code == EXIT_ERROR:
logger.error(exit_msg % ('error', exit_code)) rc_logger.error(exit_msg % ('error', exit_code))
else: else:
logger.error(exit_msg % ('abnormal', exit_code or 666)) rc_logger.error(exit_msg % ('abnormal', exit_code or 666))
sys.exit(exit_code) sys.exit(exit_code)

View File

@ -12,8 +12,14 @@ UMASK_DEFAULT = 0o077
CACHE_TAG_NAME = 'CACHEDIR.TAG' CACHE_TAG_NAME = 'CACHEDIR.TAG'
CACHE_TAG_CONTENTS = b'Signature: 8a477f597d28d172789f06886806bc55' CACHE_TAG_CONTENTS = b'Signature: 8a477f597d28d172789f06886806bc55'
DEFAULT_MAX_SEGMENT_SIZE = 5 * 1024 * 1024 # A large, but not unreasonably large segment size. Always less than 2 GiB (for legacy file systems). We choose
DEFAULT_SEGMENTS_PER_DIR = 10000 # 500 MiB which means that no indirection from the inode is needed for typical Linux file systems.
# Note that this is a soft-limit and can be exceeded (worst case) by a full maximum chunk size and some metadata
# bytes. That's why it's 500 MiB instead of 512 MiB.
DEFAULT_MAX_SEGMENT_SIZE = 500 * 1024 * 1024
# A few hundred files per directory to go easy on filesystems which don't like too many files per dir (NTFS)
DEFAULT_SEGMENTS_PER_DIR = 500
CHUNK_MIN_EXP = 19 # 2**19 == 512kiB CHUNK_MIN_EXP = 19 # 2**19 == 512kiB
CHUNK_MAX_EXP = 23 # 2**23 == 8MiB CHUNK_MAX_EXP = 23 # 2**23 == 8MiB

View File

@ -1,17 +1,11 @@
"""A thin OpenSSL wrapper """A thin OpenSSL wrapper"""
This could be replaced by PyCrypto maybe?
"""
from libc.stdlib cimport malloc, free from libc.stdlib cimport malloc, free
from cpython.buffer cimport PyBUF_SIMPLE, PyObject_GetBuffer, PyBuffer_Release from cpython.buffer cimport PyBUF_SIMPLE, PyObject_GetBuffer, PyBuffer_Release
API_VERSION = 3 API_VERSION = 3
cdef extern from "openssl/rand.h":
int RAND_bytes(unsigned char *buf, int num)
cdef extern from "openssl/evp.h": cdef extern from "openssl/evp.h":
ctypedef struct EVP_MD: ctypedef struct EVP_MD:
pass pass

View File

@ -11,7 +11,6 @@ import stat
import textwrap import textwrap
import pwd import pwd
import re import re
from shutil import get_terminal_size
import sys import sys
from string import Formatter from string import Formatter
import platform import platform
@ -82,7 +81,7 @@ def check_extension_modules():
raise ExtensionModuleError raise ExtensionModuleError
if crypto.API_VERSION != 3: if crypto.API_VERSION != 3:
raise ExtensionModuleError raise ExtensionModuleError
if platform.API_VERSION != 2: if platform.API_VERSION != 3:
raise ExtensionModuleError raise ExtensionModuleError
@ -172,57 +171,6 @@ def prune_split(archives, pattern, n, skip=[]):
return keep return keep
class Statistics:
def __init__(self):
self.osize = self.csize = self.usize = self.nfiles = 0
self.last_progress = 0 # timestamp when last progress was shown
def update(self, size, csize, unique):
self.osize += size
self.csize += csize
if unique:
self.usize += csize
summary = """\
Original size Compressed size Deduplicated size
{label:15} {stats.osize_fmt:>20s} {stats.csize_fmt:>20s} {stats.usize_fmt:>20s}"""
def __str__(self):
return self.summary.format(stats=self, label='This archive:')
def __repr__(self):
return "<{cls} object at {hash:#x} ({self.osize}, {self.csize}, {self.usize})>".format(cls=type(self).__name__, hash=id(self), self=self)
@property
def osize_fmt(self):
return format_file_size(self.osize)
@property
def usize_fmt(self):
return format_file_size(self.usize)
@property
def csize_fmt(self):
return format_file_size(self.csize)
def show_progress(self, item=None, final=False, stream=None, dt=None):
now = time.time()
if dt is None or now - self.last_progress > dt:
self.last_progress = now
columns, lines = get_terminal_size()
if not final:
msg = '{0.osize_fmt} O {0.csize_fmt} C {0.usize_fmt} D {0.nfiles} N '.format(self)
path = remove_surrogates(item[b'path']) if item else ''
space = columns - len(msg)
if space < len('...') + len(path):
path = '%s...%s' % (path[:(space // 2) - len('...')], path[-space // 2:])
msg += "{0:<{space}}".format(path, space=space)
else:
msg = ' ' * columns
print(msg, file=stream or sys.stderr, end="\r", flush=True)
def get_home_dir(): def get_home_dir():
"""Get user's home directory while preferring a possibly set HOME """Get user's home directory while preferring a possibly set HOME
environment variable environment variable
@ -282,8 +230,7 @@ def load_excludes(fh):
"""Load and parse exclude patterns from file object. Lines empty or starting with '#' after stripping whitespace on """Load and parse exclude patterns from file object. Lines empty or starting with '#' after stripping whitespace on
both line ends are ignored. both line ends are ignored.
""" """
patterns = (line for line in (i.strip() for i in fh) if not line.startswith('#')) return [parse_pattern(pattern) for pattern in clean_lines(fh)]
return [parse_pattern(pattern) for pattern in patterns if pattern]
def update_excludes(args): def update_excludes(args):
@ -734,11 +681,15 @@ def posix_acl_use_stored_uid_gid(acl):
def safe_decode(s, coding='utf-8', errors='surrogateescape'): def safe_decode(s, coding='utf-8', errors='surrogateescape'):
"""decode bytes to str, with round-tripping "invalid" bytes""" """decode bytes to str, with round-tripping "invalid" bytes"""
if s is None:
return None
return s.decode(coding, errors) return s.decode(coding, errors)
def safe_encode(s, coding='utf-8', errors='surrogateescape'): def safe_encode(s, coding='utf-8', errors='surrogateescape'):
"""encode str to bytes, with round-tripping "invalid" bytes""" """encode str to bytes, with round-tripping "invalid" bytes"""
if s is None:
return None
return s.encode(coding, errors) return s.encode(coding, errors)
@ -1038,7 +989,7 @@ def yes(msg=None, false_msg=None, true_msg=None, default_msg=None,
class ProgressIndicatorPercent: class ProgressIndicatorPercent:
def __init__(self, total, step=5, start=0, same_line=False, msg="%3.0f%%", file=None): def __init__(self, total, step=5, start=0, same_line=False, msg="%3.0f%%"):
""" """
Percentage-based progress indicator Percentage-based progress indicator
@ -1047,17 +998,33 @@ class ProgressIndicatorPercent:
:param start: at which percent value to start :param start: at which percent value to start
:param same_line: if True, emit output always on same line :param same_line: if True, emit output always on same line
:param msg: output message, must contain one %f placeholder for the percentage :param msg: output message, must contain one %f placeholder for the percentage
:param file: output file, default: sys.stderr
""" """
self.counter = 0 # 0 .. (total-1) self.counter = 0 # 0 .. (total-1)
self.total = total self.total = total
self.trigger_at = start # output next percentage value when reaching (at least) this self.trigger_at = start # output next percentage value when reaching (at least) this
self.step = step self.step = step
if file is None:
file = sys.stderr
self.file = file
self.msg = msg self.msg = msg
self.same_line = same_line self.same_line = same_line
self.handler = None
self.logger = logging.getLogger('borg.output.progress')
# If there are no handlers, set one up explicitly because the
# terminator and propagation needs to be set. If there are,
# they must have been set up by BORG_LOGGING_CONF: skip setup.
if not self.logger.handlers:
self.handler = logging.StreamHandler(stream=sys.stderr)
self.handler.setLevel(logging.INFO)
self.handler.terminator = '\r' if self.same_line else '\n'
self.logger.addHandler(self.handler)
if self.logger.level == logging.NOTSET:
self.logger.setLevel(logging.WARN)
self.logger.propagate = False
def __del__(self):
if self.handler is not None:
self.logger.removeHandler(self.handler)
self.handler.close()
def progress(self, current=None): def progress(self, current=None):
if current is not None: if current is not None:
@ -1074,11 +1041,11 @@ class ProgressIndicatorPercent:
return self.output(pct) return self.output(pct)
def output(self, percent): def output(self, percent):
print(self.msg % percent, file=self.file, end='\r' if self.same_line else '\n', flush=True) self.logger.info(self.msg % percent)
def finish(self): def finish(self):
if self.same_line: if self.same_line:
print(" " * len(self.msg % 100.0), file=self.file, end='\r') self.logger.info(" " * len(self.msg % 100.0))
class ProgressIndicatorEndless: class ProgressIndicatorEndless:
@ -1128,7 +1095,7 @@ def sysinfo():
return '\n'.join(info) return '\n'.join(info)
def log_multi(*msgs, level=logging.INFO): def log_multi(*msgs, level=logging.INFO, logger=logger):
""" """
log multiple lines of text, each line by a separate logging call for cosmetic reasons log multiple lines of text, each line by a separate logging call for cosmetic reasons
@ -1167,7 +1134,7 @@ class ItemFormatter:
'NUL': 'NUL character for creating print0 / xargs -0 like ouput, see bpath', 'NUL': 'NUL character for creating print0 / xargs -0 like ouput, see bpath',
} }
KEY_GROUPS = ( KEY_GROUPS = (
('type', 'mode', 'uid', 'gid', 'user', 'group', 'path', 'bpath', 'source', 'linktarget'), ('type', 'mode', 'uid', 'gid', 'user', 'group', 'path', 'bpath', 'source', 'linktarget', 'flags'),
('size', 'csize', 'num_chunks', 'unique_chunks'), ('size', 'csize', 'num_chunks', 'unique_chunks'),
('mtime', 'ctime', 'atime', 'isomtime', 'isoctime', 'isoatime'), ('mtime', 'ctime', 'atime', 'isomtime', 'isoctime', 'isoatime'),
tuple(sorted(hashlib.algorithms_guaranteed)), tuple(sorted(hashlib.algorithms_guaranteed)),
@ -1260,6 +1227,7 @@ class ItemFormatter:
item_data['source'] = source item_data['source'] = source
item_data['linktarget'] = source item_data['linktarget'] = source
item_data['extra'] = extra item_data['extra'] = extra
item_data['flags'] = item.get(b'bsdflags')
for key in self.used_call_keys: for key in self.used_call_keys:
item_data[key] = self.call_keys[key](item) item_data[key] = self.call_keys[key](item)
return item_data return item_data

141
src/borg/item.py Normal file
View File

@ -0,0 +1,141 @@
from .constants import ITEM_KEYS
from .helpers import safe_encode, safe_decode, bigint_to_int, int_to_bigint, StableDict
class PropDict:
"""
Manage a dictionary via properties.
- initialization by giving a dict or kw args
- on initialization, normalize dict keys to be str type
- access dict via properties, like: x.key_name
- membership check via: 'key_name' in x
- optionally, encode when setting a value
- optionally, decode when getting a value
- be safe against typos in key names: check against VALID_KEYS
- when setting a value: check type of value
"""
VALID_KEYS = None # override with <set of str> in child class
__slots__ = ("_dict", ) # avoid setting attributes not supported by properties
def __init__(self, data_dict=None, **kw):
if data_dict is None:
data = kw
elif not isinstance(data_dict, dict):
raise TypeError("data_dict must be dict")
else:
data = data_dict
# internally, we want an dict with only str-typed keys
_dict = {}
for k, v in data.items():
if isinstance(k, bytes):
k = k.decode()
elif not isinstance(k, str):
raise TypeError("dict keys must be str or bytes, not %r" % k)
_dict[k] = v
unknown_keys = set(_dict) - self.VALID_KEYS
if unknown_keys:
raise ValueError("dict contains unknown keys %s" % ','.join(unknown_keys))
self._dict = _dict
def as_dict(self):
"""return the internal dictionary"""
return StableDict(self._dict)
def _check_key(self, key):
"""make sure key is of type str and known"""
if not isinstance(key, str):
raise TypeError("key must be str")
if key not in self.VALID_KEYS:
raise ValueError("key '%s' is not a valid key" % key)
return key
def __contains__(self, key):
"""do we have this key?"""
return self._check_key(key) in self._dict
def get(self, key, default=None):
"""get value for key, return default if key does not exist"""
return getattr(self, self._check_key(key), default)
@staticmethod
def _make_property(key, value_type, value_type_name=None, encode=None, decode=None):
"""return a property that deals with self._dict[key]"""
assert isinstance(key, str)
if value_type_name is None:
value_type_name = value_type.__name__
doc = "%s (%s)" % (key, value_type_name)
type_error_msg = "%s value must be %s" % (key, value_type_name)
attr_error_msg = "attribute %s not found" % key
def _get(self):
try:
value = self._dict[key]
except KeyError:
raise AttributeError(attr_error_msg) from None
if decode is not None:
value = decode(value)
return value
def _set(self, value):
if not isinstance(value, value_type):
raise TypeError(type_error_msg)
if encode is not None:
value = encode(value)
self._dict[key] = value
def _del(self):
try:
del self._dict[key]
except KeyError:
raise AttributeError(attr_error_msg) from None
return property(_get, _set, _del, doc=doc)
class Item(PropDict):
"""
Item abstraction that deals with validation and the low-level details internally:
Items are created either from msgpack unpacker output, from another dict, from kwargs or
built step-by-step by setting attributes.
msgpack gives us a dict with bytes-typed keys, just give it to Item(d) and use item.key_name later.
msgpack gives us byte-typed values for stuff that should be str, we automatically decode when getting
such a property and encode when setting it.
If an Item shall be serialized, give as_dict() method output to msgpack packer.
"""
VALID_KEYS = set(key.decode() for key in ITEM_KEYS) # we want str-typed keys
__slots__ = ("_dict", ) # avoid setting attributes not supported by properties
# properties statically defined, so that IDEs can know their names:
path = PropDict._make_property('path', str, 'surrogate-escaped str', encode=safe_encode, decode=safe_decode)
source = PropDict._make_property('source', str, 'surrogate-escaped str', encode=safe_encode, decode=safe_decode)
acl_access = PropDict._make_property('acl_access', str, 'surrogate-escaped str', encode=safe_encode, decode=safe_decode)
acl_default = PropDict._make_property('acl_default', str, 'surrogate-escaped str', encode=safe_encode, decode=safe_decode)
acl_extended = PropDict._make_property('acl_extended', str, 'surrogate-escaped str', encode=safe_encode, decode=safe_decode)
acl_nfs4 = PropDict._make_property('acl_nfs4', str, 'surrogate-escaped str', encode=safe_encode, decode=safe_decode)
user = PropDict._make_property('user', (str, type(None)), 'surrogate-escaped str or None', encode=safe_encode, decode=safe_decode)
group = PropDict._make_property('group', (str, type(None)), 'surrogate-escaped str or None', encode=safe_encode, decode=safe_decode)
mode = PropDict._make_property('mode', int)
uid = PropDict._make_property('uid', int)
gid = PropDict._make_property('gid', int)
rdev = PropDict._make_property('rdev', int)
bsdflags = PropDict._make_property('bsdflags', int)
atime = PropDict._make_property('atime', int, 'bigint', encode=int_to_bigint, decode=bigint_to_int)
ctime = PropDict._make_property('ctime', int, 'bigint', encode=int_to_bigint, decode=bigint_to_int)
mtime = PropDict._make_property('mtime', int, 'bigint', encode=int_to_bigint, decode=bigint_to_int)
hardlink_master = PropDict._make_property('hardlink_master', bool)
chunks = PropDict._make_property('chunks', list)
xattrs = PropDict._make_property('xattrs', StableDict)

View File

@ -1,4 +1,4 @@
from binascii import a2b_base64, b2a_base64 from binascii import a2b_base64, b2a_base64, hexlify
import configparser import configparser
import getpass import getpass
import os import os
@ -413,16 +413,19 @@ class KeyfileKey(KeyfileKeyBase):
FILE_ID = 'BORG_KEY' FILE_ID = 'BORG_KEY'
def sanity_check(self, filename, id): def sanity_check(self, filename, id):
with open(filename, 'r') as fd: file_id = self.FILE_ID.encode() + b' '
line = fd.readline().strip() repo_id = hexlify(id)
if not line.startswith(self.FILE_ID): with open(filename, 'rb') as fd:
# we do the magic / id check in binary mode to avoid stumbling over
# decoding errors if somebody has binary files in the keys dir for some reason.
if fd.read(len(file_id)) != file_id:
raise KeyfileInvalidError(self.repository._location.canonical_path(), filename) raise KeyfileInvalidError(self.repository._location.canonical_path(), filename)
if line[len(self.FILE_ID) + 1:] != id: if fd.read(len(repo_id)) != repo_id:
raise KeyfileMismatchError(self.repository._location.canonical_path(), filename) raise KeyfileMismatchError(self.repository._location.canonical_path(), filename)
return filename return filename
def find_key(self): def find_key(self):
id = self.repository.id_str id = self.repository.id
keyfile = os.environ.get('BORG_KEY_FILE') keyfile = os.environ.get('BORG_KEY_FILE')
if keyfile: if keyfile:
return self.sanity_check(keyfile, id) return self.sanity_check(keyfile, id)

View File

@ -88,7 +88,7 @@ def setup_logging(stream=None, conf_fname=None, env_var='BORG_LOGGING_CONF', lev
logger = logging.getLogger('') logger = logging.getLogger('')
handler = logging.StreamHandler(stream) handler = logging.StreamHandler(stream)
if is_serve: if is_serve:
fmt = '$LOG %(levelname)s Remote: %(message)s' fmt = '$LOG %(levelname)s %(name)s Remote: %(message)s'
else: else:
fmt = '%(message)s' fmt = '%(message)s'
handler.setFormatter(logging.Formatter(fmt)) handler.setFormatter(logging.Formatter(fmt))

View File

@ -1,16 +1,10 @@
import sys import sys
from .platform_base import acl_get, acl_set, SyncFile, sync_dir, set_flags, get_flags, swidth, API_VERSION
if sys.platform.startswith('linux'): # pragma: linux only if sys.platform.startswith('linux'): # pragma: linux only
from .platform_linux import acl_get, acl_set, API_VERSION from .platform_linux import acl_get, acl_set, SyncFile, set_flags, get_flags, swidth, API_VERSION
elif sys.platform.startswith('freebsd'): # pragma: freebsd only elif sys.platform.startswith('freebsd'): # pragma: freebsd only
from .platform_freebsd import acl_get, acl_set, API_VERSION from .platform_freebsd import acl_get, acl_set, swidth, API_VERSION
elif sys.platform == 'darwin': # pragma: darwin only elif sys.platform == 'darwin': # pragma: darwin only
from .platform_darwin import acl_get, acl_set, API_VERSION from .platform_darwin import acl_get, acl_set, swidth, API_VERSION
else: # pragma: unknown platform only
API_VERSION = 2
def acl_get(path, item, st, numeric_owner=False):
pass
def acl_set(path, item, numeric_owner=False):
pass

100
src/borg/platform_base.py Normal file
View File

@ -0,0 +1,100 @@
import os
API_VERSION = 3
fdatasync = getattr(os, 'fdatasync', os.fsync)
def acl_get(path, item, st, numeric_owner=False):
"""
Saves ACL Entries
If `numeric_owner` is True the user/group field is not preserved only uid/gid
"""
def acl_set(path, item, numeric_owner=False):
"""
Restore ACL Entries
If `numeric_owner` is True the stored uid/gid is used instead
of the user/group names
"""
try:
from os import lchflags
def set_flags(path, bsd_flags, fd=None):
lchflags(path, bsd_flags)
except ImportError:
def set_flags(path, bsd_flags, fd=None):
pass
def get_flags(path, st):
"""Return BSD-style file flags for path or stat without following symlinks."""
return getattr(st, 'st_flags', 0)
def sync_dir(path):
fd = os.open(path, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
class SyncFile:
"""
A file class that is supposed to enable write ordering (one way or another) and data durability after close().
The degree to which either is possible varies with operating system, file system and hardware.
This fallback implements a naive and slow way of doing this. On some operating systems it can't actually
guarantee any of the above, since fsync() doesn't guarantee it. Furthermore it may not be possible at all
to satisfy the above guarantees on some hardware or operating systems. In these cases we hope that the thorough
checksumming implemented catches any corrupted data due to misordered, delayed or partial writes.
Note that POSIX doesn't specify *anything* about power failures (or similar failures). A system that
routinely loses files or corrupts file on power loss is POSIX compliant.
TODO: Use F_FULLSYNC on OSX.
TODO: A Windows implementation should use CreateFile with FILE_FLAG_WRITE_THROUGH.
"""
def __init__(self, path):
self.fd = open(path, 'wb')
self.fileno = self.fd.fileno()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def write(self, data):
self.fd.write(data)
def sync(self):
"""
Synchronize file contents. Everything written prior to sync() must become durable before anything written
after sync().
"""
self.fd.flush()
fdatasync(self.fileno)
if hasattr(os, 'posix_fadvise'):
os.posix_fadvise(self.fileno, 0, 0, os.POSIX_FADV_DONTNEED)
def close(self):
"""sync() and close."""
self.sync()
self.fd.close()
sync_dir(os.path.dirname(self.fd.name))
def swidth(s):
"""terminal output width of string <s>
For western scripts, this is just len(s), but for cjk glyphs, 2 cells are used.
"""
return len(s)

View File

@ -1,7 +1,8 @@
import os import os
from .helpers import user2uid, group2gid, safe_decode, safe_encode from .helpers import user2uid, group2gid, safe_decode, safe_encode
from .platform_posix import swidth
API_VERSION = 2 API_VERSION = 3
cdef extern from "sys/acl.h": cdef extern from "sys/acl.h":
ctypedef struct _acl_t: ctypedef struct _acl_t:

View File

@ -1,7 +1,8 @@
import os import os
from .helpers import posix_acl_use_stored_uid_gid, safe_encode, safe_decode from .helpers import posix_acl_use_stored_uid_gid, safe_encode, safe_decode
from .platform_posix import swidth
API_VERSION = 2 API_VERSION = 3
cdef extern from "errno.h": cdef extern from "errno.h":
int errno int errno

View File

@ -1,13 +1,20 @@
import os import os
import re import re
from stat import S_ISLNK import resource
from .helpers import posix_acl_use_stored_uid_gid, user2uid, group2gid, safe_decode, safe_encode import stat
API_VERSION = 2 from .helpers import posix_acl_use_stored_uid_gid, user2uid, group2gid, safe_decode, safe_encode
from .platform_base import SyncFile as BaseSyncFile
from .platform_posix import swidth
from libc cimport errno
API_VERSION = 3
cdef extern from "sys/types.h": cdef extern from "sys/types.h":
int ACL_TYPE_ACCESS int ACL_TYPE_ACCESS
int ACL_TYPE_DEFAULT int ACL_TYPE_DEFAULT
ctypedef off64_t
cdef extern from "sys/acl.h": cdef extern from "sys/acl.h":
ctypedef struct _acl_t: ctypedef struct _acl_t:
@ -23,10 +30,78 @@ cdef extern from "sys/acl.h":
cdef extern from "acl/libacl.h": cdef extern from "acl/libacl.h":
int acl_extended_file(const char *path) int acl_extended_file(const char *path)
cdef extern from "fcntl.h":
int sync_file_range(int fd, off64_t offset, off64_t nbytes, unsigned int flags)
unsigned int SYNC_FILE_RANGE_WRITE
unsigned int SYNC_FILE_RANGE_WAIT_BEFORE
unsigned int SYNC_FILE_RANGE_WAIT_AFTER
cdef extern from "linux/fs.h":
# ioctls
int FS_IOC_SETFLAGS
int FS_IOC_GETFLAGS
# inode flags
int FS_NODUMP_FL
int FS_IMMUTABLE_FL
int FS_APPEND_FL
int FS_COMPR_FL
cdef extern from "stropts.h":
int ioctl(int fildes, int request, ...)
cdef extern from "errno.h":
int errno
cdef extern from "string.h":
char *strerror(int errnum)
_comment_re = re.compile(' *#.*', re.M) _comment_re = re.compile(' *#.*', re.M)
BSD_TO_LINUX_FLAGS = {
stat.UF_NODUMP: FS_NODUMP_FL,
stat.UF_IMMUTABLE: FS_IMMUTABLE_FL,
stat.UF_APPEND: FS_APPEND_FL,
stat.UF_COMPRESSED: FS_COMPR_FL,
}
def set_flags(path, bsd_flags, fd=None):
if fd is None and stat.S_ISLNK(os.lstat(path).st_mode):
return
cdef int flags = 0
for bsd_flag, linux_flag in BSD_TO_LINUX_FLAGS.items():
if bsd_flags & bsd_flag:
flags |= linux_flag
open_fd = fd is None
if open_fd:
fd = os.open(path, os.O_RDONLY|os.O_NONBLOCK|os.O_NOFOLLOW)
try:
if ioctl(fd, FS_IOC_SETFLAGS, &flags) == -1:
raise OSError(errno, strerror(errno).decode(), path)
finally:
if open_fd:
os.close(fd)
def get_flags(path, st):
if stat.S_ISLNK(st.st_mode):
return 0
cdef int linux_flags
fd = os.open(path, os.O_RDONLY|os.O_NONBLOCK|os.O_NOFOLLOW)
try:
if ioctl(fd, FS_IOC_GETFLAGS, &linux_flags) == -1:
return 0
finally:
os.close(fd)
bsd_flags = 0
for bsd_flag, linux_flag in BSD_TO_LINUX_FLAGS.items():
if linux_flags & linux_flag:
bsd_flags |= bsd_flag
return bsd_flags
def acl_use_local_uid_gid(acl): def acl_use_local_uid_gid(acl):
"""Replace the user/group field with the local uid/gid if possible """Replace the user/group field with the local uid/gid if possible
""" """
@ -77,17 +152,13 @@ cdef acl_numeric_ids(acl):
def acl_get(path, item, st, numeric_owner=False): def acl_get(path, item, st, numeric_owner=False):
"""Saves ACL Entries
If `numeric_owner` is True the user/group field is not preserved only uid/gid
"""
cdef acl_t default_acl = NULL cdef acl_t default_acl = NULL
cdef acl_t access_acl = NULL cdef acl_t access_acl = NULL
cdef char *default_text = NULL cdef char *default_text = NULL
cdef char *access_text = NULL cdef char *access_text = NULL
p = <bytes>os.fsencode(path) p = <bytes>os.fsencode(path)
if S_ISLNK(st.st_mode) or acl_extended_file(p) <= 0: if stat.S_ISLNK(st.st_mode) or acl_extended_file(p) <= 0:
return return
if numeric_owner: if numeric_owner:
converter = acl_numeric_ids converter = acl_numeric_ids
@ -112,11 +183,6 @@ def acl_get(path, item, st, numeric_owner=False):
def acl_set(path, item, numeric_owner=False): def acl_set(path, item, numeric_owner=False):
"""Restore ACL Entries
If `numeric_owner` is True the stored uid/gid is used instead
of the user/group names
"""
cdef acl_t access_acl = NULL cdef acl_t access_acl = NULL
cdef acl_t default_acl = NULL cdef acl_t default_acl = NULL
@ -141,3 +207,45 @@ def acl_set(path, item, numeric_owner=False):
acl_set_file(p, ACL_TYPE_DEFAULT, default_acl) acl_set_file(p, ACL_TYPE_DEFAULT, default_acl)
finally: finally:
acl_free(default_acl) acl_free(default_acl)
cdef _sync_file_range(fd, offset, length, flags):
assert offset & PAGE_MASK == 0, "offset %d not page-aligned" % offset
assert length & PAGE_MASK == 0, "length %d not page-aligned" % length
if sync_file_range(fd, offset, length, flags) != 0:
raise OSError(errno, os.strerror(errno))
os.posix_fadvise(fd, offset, length, os.POSIX_FADV_DONTNEED)
cdef unsigned PAGE_MASK = resource.getpagesize() - 1
class SyncFile(BaseSyncFile):
"""
Implemented using sync_file_range for asynchronous write-out and fdatasync for actual durability.
"write-out" means that dirty pages (= data that was written) are submitted to an I/O queue and will be send to
disk in the immediate future.
"""
def __init__(self, path):
super().__init__(path)
self.offset = 0
self.write_window = (16 * 1024 ** 2) & ~PAGE_MASK
self.last_sync = 0
self.pending_sync = None
def write(self, data):
self.offset += self.fd.write(data)
offset = self.offset & ~PAGE_MASK
if offset >= self.last_sync + self.write_window:
self.fd.flush()
_sync_file_range(self.fileno, self.last_sync, offset - self.last_sync, SYNC_FILE_RANGE_WRITE)
if self.pending_sync is not None:
_sync_file_range(self.fileno, self.pending_sync, self.last_sync - self.pending_sync,
SYNC_FILE_RANGE_WRITE | SYNC_FILE_RANGE_WAIT_BEFORE | SYNC_FILE_RANGE_WAIT_AFTER)
self.pending_sync = self.last_sync
self.last_sync = offset
def sync(self):
self.fd.flush()
os.fdatasync(self.fileno)
os.posix_fadvise(self.fileno, 0, 0, os.POSIX_FADV_DONTNEED)

View File

@ -0,0 +1,5 @@
cdef extern from "wchar.h":
cdef int wcswidth(const Py_UNICODE *str, size_t n)
def swidth(s):
return wcswidth(s, len(s))

View File

@ -301,7 +301,13 @@ class RemoteRepository:
if line.startswith('$LOG '): if line.startswith('$LOG '):
_, level, msg = line.split(' ', 2) _, level, msg = line.split(' ', 2)
level = getattr(logging, level, logging.CRITICAL) # str -> int level = getattr(logging, level, logging.CRITICAL) # str -> int
logging.log(level, msg.rstrip()) if msg.startswith('Remote:'):
# server format: '$LOG <level> Remote: <msg>'
logging.log(level, msg.rstrip())
else:
# server format '$LOG <level> <logname> Remote: <msg>'
logname, msg = msg.split(' ', 1)
logging.getLogger(logname).log(level, msg.rstrip())
else: else:
sys.stderr.write("Remote: " + line) sys.stderr.write("Remote: " + line)
if w: if w:
@ -418,6 +424,9 @@ class RepositoryCache(RepositoryNoCache):
Caches Repository GET operations using a local temporary Repository. Caches Repository GET operations using a local temporary Repository.
""" """
# maximum object size that will be cached, 64 kiB.
THRESHOLD = 2**16
def __init__(self, repository): def __init__(self, repository):
super().__init__(repository) super().__init__(repository)
tmppath = tempfile.mkdtemp(prefix='borg-tmp') tmppath = tempfile.mkdtemp(prefix='borg-tmp')
@ -438,7 +447,8 @@ class RepositoryCache(RepositoryNoCache):
except Repository.ObjectNotFound: except Repository.ObjectNotFound:
for key_, data in repository_iterator: for key_, data in repository_iterator:
if key_ == key: if key_ == key:
self.caching_repo.put(key, data) if len(data) <= self.THRESHOLD:
self.caching_repo.put(key, data)
yield data yield data
break break
# Consume any pending requests # Consume any pending requests

View File

@ -17,6 +17,7 @@ from .helpers import Error, ErrorWithTraceback, IntegrityError, Location, Progre
from .hashindex import NSIndex from .hashindex import NSIndex
from .locking import UpgradableLock, LockError, LockErrorT from .locking import UpgradableLock, LockError, LockErrorT
from .lrucache import LRUCache from .lrucache import LRUCache
from .platform import SyncFile, sync_dir
MAX_OBJECT_SIZE = 20 * 1024 * 1024 MAX_OBJECT_SIZE = 20 * 1024 * 1024
MAGIC = b'BORG_SEG' MAGIC = b'BORG_SEG'
@ -32,7 +33,7 @@ class Repository:
On disk layout: On disk layout:
dir/README dir/README
dir/config dir/config
dir/data/<X / SEGMENTS_PER_DIR>/<X> dir/data/<X // SEGMENTS_PER_DIR>/<X>
dir/index.X dir/index.X
dir/hints.X dir/hints.X
""" """
@ -507,7 +508,7 @@ class LoggedIO:
def __init__(self, path, limit, segments_per_dir, capacity=90): def __init__(self, path, limit, segments_per_dir, capacity=90):
self.path = path self.path = path
self.fds = LRUCache(capacity, self.fds = LRUCache(capacity,
dispose=lambda fd: fd.close()) dispose=self.close_fd)
self.segment = 0 self.segment = 0
self.limit = limit self.limit = limit
self.segments_per_dir = segments_per_dir self.segments_per_dir = segments_per_dir
@ -519,6 +520,11 @@ class LoggedIO:
self.fds.clear() self.fds.clear()
self.fds = None # Just to make sure we're disabled self.fds = None # Just to make sure we're disabled
def close_fd(self, fd):
if hasattr(os, 'posix_fadvise'): # only on UNIX
os.posix_fadvise(fd.fileno(), 0, 0, os.POSIX_FADV_DONTNEED)
fd.close()
def segment_iterator(self, reverse=False): def segment_iterator(self, reverse=False):
data_path = os.path.join(self.path, 'data') data_path = os.path.join(self.path, 'data')
dirs = sorted((dir for dir in os.listdir(data_path) if dir.isdigit()), key=int, reverse=reverse) dirs = sorted((dir for dir in os.listdir(data_path) if dir.isdigit()), key=int, reverse=reverse)
@ -535,10 +541,10 @@ class LoggedIO:
return None return None
def get_segments_transaction_id(self): def get_segments_transaction_id(self):
"""Verify that the transaction id is consistent with the index transaction id """Return the last committed segment.
""" """
for segment, filename in self.segment_iterator(reverse=True): for segment, filename in self.segment_iterator(reverse=True):
if self.is_committed_segment(filename): if self.is_committed_segment(segment):
return segment return segment
return None return None
@ -552,10 +558,14 @@ class LoggedIO:
else: else:
break break
def is_committed_segment(self, filename): def is_committed_segment(self, segment):
"""Check if segment ends with a COMMIT_TAG tag """Check if segment ends with a COMMIT_TAG tag
""" """
with open(filename, 'rb') as fd: try:
iterator = self.iter_objects(segment)
except IntegrityError:
return False
with open(self.segment_filename(segment), 'rb') as fd:
try: try:
fd.seek(-self.header_fmt.size, os.SEEK_END) fd.seek(-self.header_fmt.size, os.SEEK_END)
except OSError as e: except OSError as e:
@ -563,7 +573,22 @@ class LoggedIO:
if e.errno == errno.EINVAL: if e.errno == errno.EINVAL:
return False return False
raise e raise e
return fd.read(self.header_fmt.size) == self.COMMIT if fd.read(self.header_fmt.size) != self.COMMIT:
return False
seen_commit = False
while True:
try:
tag, key, offset = next(iterator)
except IntegrityError:
return False
except StopIteration:
break
if tag == TAG_COMMIT:
seen_commit = True
continue
if seen_commit:
return False
return seen_commit
def segment_filename(self, segment): def segment_filename(self, segment):
return os.path.join(self.path, 'data', str(segment // self.segments_per_dir), str(segment)) return os.path.join(self.path, 'data', str(segment // self.segments_per_dir), str(segment))
@ -578,7 +603,8 @@ class LoggedIO:
dirname = os.path.join(self.path, 'data', str(self.segment // self.segments_per_dir)) dirname = os.path.join(self.path, 'data', str(self.segment // self.segments_per_dir))
if not os.path.exists(dirname): if not os.path.exists(dirname):
os.mkdir(dirname) os.mkdir(dirname)
self._write_fd = open(self.segment_filename(self.segment), 'ab') sync_dir(os.path.join(self.path, 'data'))
self._write_fd = SyncFile(self.segment_filename(self.segment))
self._write_fd.write(MAGIC) self._write_fd.write(MAGIC)
self.offset = MAGIC_LEN self.offset = MAGIC_LEN
return self._write_fd return self._write_fd
@ -591,6 +617,13 @@ class LoggedIO:
self.fds[segment] = fd self.fds[segment] = fd
return fd return fd
def close_segment(self):
if self._write_fd:
self.segment += 1
self.offset = 0
self._write_fd.close()
self._write_fd = None
def delete_segment(self, segment): def delete_segment(self, segment):
if segment in self.fds: if segment in self.fds:
del self.fds[segment] del self.fds[segment]
@ -641,7 +674,7 @@ class LoggedIO:
def read(self, segment, offset, id): def read(self, segment, offset, id):
if segment == self.segment and self._write_fd: if segment == self.segment and self._write_fd:
self._write_fd.flush() self._write_fd.sync()
fd = self.get_fd(segment) fd = self.get_fd(segment)
fd.seek(offset) fd.seek(offset)
header = fd.read(self.put_header_fmt.size) header = fd.read(self.put_header_fmt.size)
@ -703,20 +736,8 @@ class LoggedIO:
def write_commit(self): def write_commit(self):
fd = self.get_write_fd(no_new=True) fd = self.get_write_fd(no_new=True)
fd.sync()
header = self.header_no_crc_fmt.pack(self.header_fmt.size, TAG_COMMIT) header = self.header_no_crc_fmt.pack(self.header_fmt.size, TAG_COMMIT)
crc = self.crc_fmt.pack(crc32(header) & 0xffffffff) crc = self.crc_fmt.pack(crc32(header) & 0xffffffff)
fd.write(b''.join((crc, header))) fd.write(b''.join((crc, header)))
self.close_segment() self.close_segment()
def close_segment(self):
if self._write_fd:
self.segment += 1
self.offset = 0
self._write_fd.flush()
os.fsync(self._write_fd.fileno())
if hasattr(os, 'posix_fadvise'): # only on UNIX
# tell the OS that it does not need to cache what we just wrote,
# avoids spoiling the cache for the OS and other processes.
os.posix_fadvise(self._write_fd.fileno(), 0, 0, os.POSIX_FADV_DONTNEED)
self._write_fd.close()
self._write_fd = None

View File

@ -5,9 +5,13 @@ import posix
import stat import stat
import sys import sys
import sysconfig import sysconfig
import tempfile
import time import time
import unittest import unittest
from ..xattr import get_all from ..xattr import get_all
from ..platform import get_flags
from .. import platform
# Note: this is used by borg.selftest, do not use or import py.test functionality here. # Note: this is used by borg.selftest, do not use or import py.test functionality here.
@ -23,8 +27,20 @@ try:
except ImportError: except ImportError:
raises = None raises = None
has_lchflags = hasattr(os, 'lchflags') has_lchflags = hasattr(os, 'lchflags') or sys.platform.startswith('linux')
no_lchlfags_because = '' if has_lchflags else '(not supported on this platform)'
try:
with tempfile.NamedTemporaryFile() as file:
platform.set_flags(file.name, stat.UF_NODUMP)
except OSError:
has_lchflags = False
no_lchlfags_because = '(the file system at %s does not support flags)' % tempfile.gettempdir()
try:
import llfuse
has_llfuse = True or llfuse # avoids "unused import"
except ImportError:
has_llfuse = False
# The mtime get/set precision varies on different OS and Python versions # The mtime get/set precision varies on different OS and Python versions
if 'HAVE_FUTIMENS' in getattr(posix, '_have_functions', []): if 'HAVE_FUTIMENS' in getattr(posix, '_have_functions', []):
@ -75,13 +91,13 @@ class BaseTestCase(unittest.TestCase):
# Assume path2 is on FUSE if st_dev is different # Assume path2 is on FUSE if st_dev is different
fuse = s1.st_dev != s2.st_dev fuse = s1.st_dev != s2.st_dev
attrs = ['st_mode', 'st_uid', 'st_gid', 'st_rdev'] attrs = ['st_mode', 'st_uid', 'st_gid', 'st_rdev']
if has_lchflags:
attrs.append('st_flags')
if not fuse or not os.path.isdir(path1): if not fuse or not os.path.isdir(path1):
# dir nlink is always 1 on our fuse filesystem # dir nlink is always 1 on our fuse filesystem
attrs.append('st_nlink') attrs.append('st_nlink')
d1 = [filename] + [getattr(s1, a) for a in attrs] d1 = [filename] + [getattr(s1, a) for a in attrs]
d2 = [filename] + [getattr(s2, a) for a in attrs] d2 = [filename] + [getattr(s2, a) for a in attrs]
d1.append(get_flags(path1, s1))
d2.append(get_flags(path2, s2))
# ignore st_rdev if file is not a block/char device, fixes #203 # ignore st_rdev if file is not a block/char device, fixes #203
if not stat.S_ISCHR(d1[1]) and not stat.S_ISBLK(d1[1]): if not stat.S_ISCHR(d1[1]) and not stat.S_ISBLK(d1[1]):
d1[4] = None d1[4] = None

View File

@ -1,14 +1,64 @@
import os
from datetime import datetime, timezone from datetime import datetime, timezone
from io import StringIO
from unittest.mock import Mock from unittest.mock import Mock
import pytest
import msgpack import msgpack
from ..archive import Archive, CacheChunkBuffer, RobustUnpacker from ..archive import Archive, CacheChunkBuffer, RobustUnpacker, Statistics
from ..key import PlaintextKey from ..key import PlaintextKey
from ..helpers import Manifest from ..helpers import Manifest
from . import BaseTestCase from . import BaseTestCase
@pytest.fixture()
def stats():
stats = Statistics()
stats.update(20, 10, unique=True)
return stats
def test_stats_basic(stats):
assert stats.osize == 20
assert stats.csize == stats.usize == 10
stats.update(20, 10, unique=False)
assert stats.osize == 40
assert stats.csize == 20
assert stats.usize == 10
def tests_stats_progress(stats, columns=80):
os.environ['COLUMNS'] = str(columns)
out = StringIO()
stats.show_progress(stream=out)
s = '20 B O 10 B C 10 B D 0 N '
buf = ' ' * (columns - len(s))
assert out.getvalue() == s + buf + "\r"
out = StringIO()
stats.update(10**3, 0, unique=False)
stats.show_progress(item={b'path': 'foo'}, final=False, stream=out)
s = '1.02 kB O 10 B C 10 B D 0 N foo'
buf = ' ' * (columns - len(s))
assert out.getvalue() == s + buf + "\r"
out = StringIO()
stats.show_progress(item={b'path': 'foo'*40}, final=False, stream=out)
s = '1.02 kB O 10 B C 10 B D 0 N foofoofoofoofoofoofoofo...oofoofoofoofoofoofoofoofoo'
buf = ' ' * (columns - len(s))
assert out.getvalue() == s + buf + "\r"
def test_stats_format(stats):
assert str(stats) == """\
Original size Compressed size Deduplicated size
This archive: 20 B 10 B 10 B"""
s = "{0.osize_fmt}".format(stats)
assert s == "20 B"
# kind of redundant, but id is variable so we can't match reliably
assert repr(stats) == '<Statistics object at {:#x} (20, 10, 10)>'.format(id(stats))
class MockCache: class MockCache:
def __init__(self): def __init__(self):

View File

@ -3,6 +3,7 @@ import errno
import os import os
import inspect import inspect
from io import StringIO from io import StringIO
import logging
import random import random
import stat import stat
import subprocess import subprocess
@ -16,7 +17,7 @@ from hashlib import sha256
import pytest import pytest
from .. import xattr, helpers from .. import xattr, helpers, platform
from ..archive import Archive, ChunkBuffer, ArchiveRecreater from ..archive import Archive, ChunkBuffer, ArchiveRecreater
from ..archiver import Archiver from ..archiver import Archiver
from ..cache import Cache from ..cache import Cache
@ -26,15 +27,13 @@ from ..helpers import Chunk, Manifest, EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, b
from ..key import KeyfileKeyBase from ..key import KeyfileKeyBase
from ..remote import RemoteRepository, PathNotAllowed from ..remote import RemoteRepository, PathNotAllowed
from ..repository import Repository from ..repository import Repository
from . import has_lchflags, has_llfuse
from . import BaseTestCase, changedir, environment_variable from . import BaseTestCase, changedir, environment_variable
try: try:
import llfuse import llfuse
has_llfuse = True or llfuse # avoids "unused import"
except ImportError: except ImportError:
has_llfuse = False pass
has_lchflags = hasattr(os, 'lchflags')
src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
@ -280,7 +279,7 @@ class ArchiverTestCaseBase(BaseTestCase):
# FIFO node # FIFO node
os.mkfifo(os.path.join(self.input_path, 'fifo1')) os.mkfifo(os.path.join(self.input_path, 'fifo1'))
if has_lchflags: if has_lchflags:
os.lchflags(os.path.join(self.input_path, 'flagfile'), stat.UF_NODUMP) platform.set_flags(os.path.join(self.input_path, 'flagfile'), stat.UF_NODUMP)
try: try:
# Block device # Block device
os.mknod('input/bdev', 0o600 | stat.S_IFBLK, os.makedev(10, 20)) os.mknod('input/bdev', 0o600 | stat.S_IFBLK, os.makedev(10, 20))
@ -299,9 +298,14 @@ class ArchiverTestCaseBase(BaseTestCase):
class ArchiverTestCase(ArchiverTestCaseBase): class ArchiverTestCase(ArchiverTestCaseBase):
def test_basic_functionality(self): def test_basic_functionality(self):
have_root = self.create_test_files() have_root = self.create_test_files()
self.cmd('init', self.repository_location) # fork required to test show-rc output
output = self.cmd('init', '--show-version', '--show-rc', self.repository_location, fork=True)
self.assert_in('borgbackup version', output)
self.assert_in('terminating with success status, rc 0', output)
self.cmd('create', self.repository_location + '::test', 'input') self.cmd('create', self.repository_location + '::test', 'input')
self.cmd('create', '--stats', self.repository_location + '::test.2', 'input') output = self.cmd('create', '--stats', self.repository_location + '::test.2', 'input')
self.assert_in('Archive name: test.2', output)
self.assert_in('This archive: ', output)
with changedir('output'): with changedir('output'):
self.cmd('extract', self.repository_location + '::test') self.cmd('extract', self.repository_location + '::test')
list_output = self.cmd('list', '--short', self.repository_location) list_output = self.cmd('list', '--short', self.repository_location)
@ -353,6 +357,14 @@ class ArchiverTestCase(ArchiverTestCaseBase):
# the interesting parts of info_output2 and info_output should be same # the interesting parts of info_output2 and info_output should be same
self.assert_equal(filter(info_output), filter(info_output2)) self.assert_equal(filter(info_output), filter(info_output2))
def test_symlink_extract(self):
self.create_test_files()
self.cmd('init', self.repository_location)
self.cmd('create', self.repository_location + '::test', 'input')
with changedir('output'):
self.cmd('extract', self.repository_location + '::test')
assert os.readlink('input/link1') == 'somewhere'
def test_atime(self): def test_atime(self):
self.create_test_files() self.create_test_files()
atime, mtime = 123456780, 234567890 atime, mtime = 123456780, 234567890
@ -639,6 +651,31 @@ class ArchiverTestCase(ArchiverTestCaseBase):
self.cmd("extract", self.repository_location + "::test", "fm:input/file1", "fm:*file33*", "input/file2") self.cmd("extract", self.repository_location + "::test", "fm:input/file1", "fm:*file33*", "input/file2")
self.assert_equal(sorted(os.listdir("output/input")), ["file1", "file2", "file333"]) self.assert_equal(sorted(os.listdir("output/input")), ["file1", "file2", "file333"])
def test_extract_list_output(self):
self.cmd('init', self.repository_location)
self.create_regular_file('file', size=1024 * 80)
self.cmd('create', self.repository_location + '::test', 'input')
with changedir('output'):
output = self.cmd('extract', self.repository_location + '::test')
self.assert_not_in("input/file", output)
shutil.rmtree('output/input')
with changedir('output'):
output = self.cmd('extract', '--info', self.repository_location + '::test')
self.assert_not_in("input/file", output)
shutil.rmtree('output/input')
with changedir('output'):
output = self.cmd('extract', '--list', self.repository_location + '::test')
self.assert_in("input/file", output)
shutil.rmtree('output/input')
with changedir('output'):
output = self.cmd('extract', '--list', '--info', self.repository_location + '::test')
self.assert_in("input/file", output)
def _create_test_caches(self): def _create_test_caches(self):
self.cmd('init', self.repository_location) self.cmd('init', self.repository_location)
self.create_regular_file('file1', size=1024 * 80) self.create_regular_file('file1', size=1024 * 80)
@ -851,7 +888,8 @@ class ArchiverTestCase(ArchiverTestCaseBase):
self.cmd('extract', '--dry-run', self.repository_location + '::test.2') self.cmd('extract', '--dry-run', self.repository_location + '::test.2')
self.cmd('delete', self.repository_location + '::test') self.cmd('delete', self.repository_location + '::test')
self.cmd('extract', '--dry-run', self.repository_location + '::test.2') self.cmd('extract', '--dry-run', self.repository_location + '::test.2')
self.cmd('delete', '--stats', self.repository_location + '::test.2') output = self.cmd('delete', '--stats', self.repository_location + '::test.2')
self.assert_in('Deleted data:', output)
# Make sure all data except the manifest has been deleted # Make sure all data except the manifest has been deleted
with Repository(self.repository_path) as repository: with Repository(self.repository_path) as repository:
self.assert_equal(len(repository), 1) self.assert_equal(len(repository), 1)
@ -874,12 +912,16 @@ class ArchiverTestCase(ArchiverTestCaseBase):
self.cmd('init', self.repository_location) self.cmd('init', self.repository_location)
self.create_src_archive('test') self.create_src_archive('test')
self.cmd('extract', '--dry-run', self.repository_location + '::test') self.cmd('extract', '--dry-run', self.repository_location + '::test')
self.cmd('check', self.repository_location) output = self.cmd('check', '--show-version', self.repository_location)
self.assert_in('borgbackup version', output) # implied output even without --info given
self.assert_not_in('Starting repository check', output) # --info not given for root logger
name = sorted(os.listdir(os.path.join(self.tmpdir, 'repository', 'data', '0')), reverse=True)[0] name = sorted(os.listdir(os.path.join(self.tmpdir, 'repository', 'data', '0')), reverse=True)[0]
with open(os.path.join(self.tmpdir, 'repository', 'data', '0', name), 'r+b') as fd: with open(os.path.join(self.tmpdir, 'repository', 'data', '0', name), 'r+b') as fd:
fd.seek(100) fd.seek(100)
fd.write(b'XXXX') fd.write(b'XXXX')
self.cmd('check', self.repository_location, exit_code=1) output = self.cmd('check', '--info', self.repository_location, exit_code=1)
self.assert_in('Starting repository check', output) # --info given for root logger
# we currently need to be able to create a lock directory inside the repo: # we currently need to be able to create a lock directory inside the repo:
@pytest.mark.xfail(reason="we need to be able to create the lock directory inside the repo") @pytest.mark.xfail(reason="we need to be able to create the lock directory inside the repo")
@ -927,11 +969,11 @@ class ArchiverTestCase(ArchiverTestCaseBase):
os.utime('input/file1', (now - 5, now - 5)) # 5 seconds ago os.utime('input/file1', (now - 5, now - 5)) # 5 seconds ago
self.create_regular_file('file2', size=1024 * 80) self.create_regular_file('file2', size=1024 * 80)
self.cmd('init', self.repository_location) self.cmd('init', self.repository_location)
output = self.cmd('create', '-v', '--list', self.repository_location + '::test', 'input') output = self.cmd('create', '--list', self.repository_location + '::test', 'input')
self.assert_in("A input/file1", output) self.assert_in("A input/file1", output)
self.assert_in("A input/file2", output) self.assert_in("A input/file2", output)
# should find first file as unmodified # should find first file as unmodified
output = self.cmd('create', '-v', '--list', self.repository_location + '::test1', 'input') output = self.cmd('create', '--list', self.repository_location + '::test1', 'input')
self.assert_in("U input/file1", output) self.assert_in("U input/file1", output)
# this is expected, although surprising, for why, see: # this is expected, although surprising, for why, see:
# https://borgbackup.readthedocs.org/en/latest/faq.html#i-am-seeing-a-added-status-for-a-unchanged-file # https://borgbackup.readthedocs.org/en/latest/faq.html#i-am-seeing-a-added-status-for-a-unchanged-file
@ -945,11 +987,11 @@ class ArchiverTestCase(ArchiverTestCaseBase):
os.utime('input/file1', (now - 5, now - 5)) # 5 seconds ago os.utime('input/file1', (now - 5, now - 5)) # 5 seconds ago
self.create_regular_file('file2', size=1024 * 80) self.create_regular_file('file2', size=1024 * 80)
self.cmd('init', self.repository_location) self.cmd('init', self.repository_location)
output = self.cmd('create', '-v', '--list', self.repository_location + '::test', 'input') output = self.cmd('create', '--list', self.repository_location + '::test', 'input')
self.assert_in("A input/file1", output) self.assert_in("A input/file1", output)
self.assert_in("A input/file2", output) self.assert_in("A input/file2", output)
# should find second file as excluded # should find second file as excluded
output = self.cmd('create', '-v', '--list', self.repository_location + '::test1', 'input', '--exclude', '*/file2') output = self.cmd('create', '--list', self.repository_location + '::test1', 'input', '--exclude', '*/file2')
self.assert_in("U input/file1", output) self.assert_in("U input/file1", output)
self.assert_in("x input/file2", output) self.assert_in("x input/file2", output)
@ -966,15 +1008,15 @@ class ArchiverTestCase(ArchiverTestCaseBase):
output = self.cmd('create', self.repository_location + '::test0', 'input') output = self.cmd('create', self.repository_location + '::test0', 'input')
self.assert_not_in('file1', output) self.assert_not_in('file1', output)
# should list the file as unchanged # should list the file as unchanged
output = self.cmd('create', '-v', '--list', '--filter=U', self.repository_location + '::test1', 'input') output = self.cmd('create', '--list', '--filter=U', self.repository_location + '::test1', 'input')
self.assert_in('file1', output) self.assert_in('file1', output)
# should *not* list the file as changed # should *not* list the file as changed
output = self.cmd('create', '-v', '--filter=AM', self.repository_location + '::test2', 'input') output = self.cmd('create', '--list', '--filter=AM', self.repository_location + '::test2', 'input')
self.assert_not_in('file1', output) self.assert_not_in('file1', output)
# change the file # change the file
self.create_regular_file('file1', size=1024 * 100) self.create_regular_file('file1', size=1024 * 100)
# should list the file as changed # should list the file as changed
output = self.cmd('create', '-v', '--list', '--filter=AM', self.repository_location + '::test3', 'input') output = self.cmd('create', '--list', '--filter=AM', self.repository_location + '::test3', 'input')
self.assert_in('file1', output) self.assert_in('file1', output)
# def test_cmdline_compatibility(self): # def test_cmdline_compatibility(self):
@ -992,7 +1034,8 @@ class ArchiverTestCase(ArchiverTestCaseBase):
self.cmd('create', self.repository_location + '::test3.checkpoint', src_dir) self.cmd('create', self.repository_location + '::test3.checkpoint', src_dir)
self.cmd('create', self.repository_location + '::test3.checkpoint.1', src_dir) self.cmd('create', self.repository_location + '::test3.checkpoint.1', src_dir)
self.cmd('create', self.repository_location + '::test4.checkpoint', src_dir) self.cmd('create', self.repository_location + '::test4.checkpoint', src_dir)
output = self.cmd('prune', '-v', '--list', '--dry-run', self.repository_location, '--keep-daily=2') output = self.cmd('prune', '--list', '--dry-run', self.repository_location, '--keep-daily=2')
self.assert_in('Keeping archive: test2', output)
self.assert_in('Would prune: test1', output) self.assert_in('Would prune: test1', output)
# must keep the latest non-checkpoint archive: # must keep the latest non-checkpoint archive:
self.assert_in('Keeping archive: test2', output) self.assert_in('Keeping archive: test2', output)
@ -1026,9 +1069,10 @@ class ArchiverTestCase(ArchiverTestCaseBase):
self.cmd('init', self.repository_location) self.cmd('init', self.repository_location)
self.cmd('create', self.repository_location + '::test1', src_dir) self.cmd('create', self.repository_location + '::test1', src_dir)
self.cmd('create', self.repository_location + '::test2', src_dir) self.cmd('create', self.repository_location + '::test2', src_dir)
output = self.cmd('prune', '-v', '--list', '--dry-run', self.repository_location, '--keep-daily=2') output = self.cmd('prune', '--list', '--stats', '--dry-run', self.repository_location, '--keep-daily=2')
self.assert_in('Keeping archive: test2', output) self.assert_in('Keeping archive: test2', output)
self.assert_in('Would prune: test1', output) self.assert_in('Would prune: test1', output)
self.assert_in('Deleted data:', output)
output = self.cmd('list', self.repository_location) output = self.cmd('list', self.repository_location)
self.assert_in('test1', output) self.assert_in('test1', output)
self.assert_in('test2', output) self.assert_in('test2', output)
@ -1043,7 +1087,7 @@ class ArchiverTestCase(ArchiverTestCaseBase):
self.cmd('create', self.repository_location + '::foo-2015-08-12-20:00', src_dir) self.cmd('create', self.repository_location + '::foo-2015-08-12-20:00', src_dir)
self.cmd('create', self.repository_location + '::bar-2015-08-12-10:00', src_dir) self.cmd('create', self.repository_location + '::bar-2015-08-12-10:00', src_dir)
self.cmd('create', self.repository_location + '::bar-2015-08-12-20:00', src_dir) self.cmd('create', self.repository_location + '::bar-2015-08-12-20:00', src_dir)
output = self.cmd('prune', '-v', '--list', '--dry-run', self.repository_location, '--keep-daily=2', '--prefix=foo-') output = self.cmd('prune', '--list', '--dry-run', self.repository_location, '--keep-daily=2', '--prefix=foo-')
self.assert_in('Keeping archive: foo-2015-08-12-20:00', output) self.assert_in('Keeping archive: foo-2015-08-12-20:00', output)
self.assert_in('Would prune: foo-2015-08-12-10:00', output) self.assert_in('Would prune: foo-2015-08-12-10:00', output)
output = self.cmd('list', self.repository_location) output = self.cmd('list', self.repository_location)
@ -1395,7 +1439,7 @@ class ArchiverTestCase(ArchiverTestCaseBase):
if interrupt_early: if interrupt_early:
process_files = 0 process_files = 0
with patch.object(ArchiveRecreater, 'process_item', self._recreate_interrupt_patch(process_files)): with patch.object(ArchiveRecreater, 'process_item', self._recreate_interrupt_patch(process_files)):
self.cmd('recreate', '-sv', '--list', self.repository_location, 'input/dir2') self.cmd('recreate', self.repository_location, 'input/dir2')
assert 'test.recreate' in self.cmd('list', self.repository_location) assert 'test.recreate' in self.cmd('list', self.repository_location)
if change_args: if change_args:
with patch.object(sys, 'argv', sys.argv + ['non-forking tests don\'t use sys.argv']): with patch.object(sys, 'argv', sys.argv + ['non-forking tests don\'t use sys.argv']):
@ -1486,6 +1530,32 @@ class ArchiverTestCase(ArchiverTestCaseBase):
cmd = 'python3', '-c', 'import os, sys; sys.exit(42 if os.path.exists("%s") else 23)' % lock_path cmd = 'python3', '-c', 'import os, sys; sys.exit(42 if os.path.exists("%s") else 23)' % lock_path
self.cmd('with-lock', self.repository_location, *cmd, fork=True, exit_code=42) self.cmd('with-lock', self.repository_location, *cmd, fork=True, exit_code=42)
def test_recreate_list_output(self):
self.cmd('init', self.repository_location)
self.create_regular_file('file1', size=0)
self.create_regular_file('file2', size=0)
self.create_regular_file('file3', size=0)
self.create_regular_file('file4', size=0)
self.create_regular_file('file5', size=0)
self.cmd('create', self.repository_location + '::test', 'input')
output = self.cmd('recreate', '--list', '--info', self.repository_location + '::test', '-e', 'input/file2')
self.assert_in("input/file1", output)
self.assert_in("x input/file2", output)
output = self.cmd('recreate', '--list', self.repository_location + '::test', '-e', 'input/file3')
self.assert_in("input/file1", output)
self.assert_in("x input/file3", output)
output = self.cmd('recreate', self.repository_location + '::test', '-e', 'input/file4')
self.assert_not_in("input/file1", output)
self.assert_not_in("x input/file4", output)
output = self.cmd('recreate', '--info', self.repository_location + '::test', '-e', 'input/file5')
self.assert_not_in("input/file1", output)
self.assert_not_in("x input/file5", output)
@unittest.skipUnless('binary' in BORG_EXES, 'no borg.exe available') @unittest.skipUnless('binary' in BORG_EXES, 'no borg.exe available')
class ArchiverTestCaseBinary(ArchiverTestCase): class ArchiverTestCaseBinary(ArchiverTestCase):
@ -1526,12 +1596,16 @@ class ArchiverCheckTestCase(ArchiverTestCaseBase):
return archive, repository return archive, repository
def test_check_usage(self): def test_check_usage(self):
output = self.cmd('check', '-v', self.repository_location, exit_code=0) output = self.cmd('check', '-v', '--progress', self.repository_location, exit_code=0)
self.assert_in('Starting repository check', output) self.assert_in('Starting repository check', output)
self.assert_in('Starting archive consistency check', output) self.assert_in('Starting archive consistency check', output)
self.assert_in('Checking segments', output)
# reset logging to new process default to avoid need for fork=True on next check
logging.getLogger('borg.output.progress').setLevel(logging.NOTSET)
output = self.cmd('check', '-v', '--repository-only', self.repository_location, exit_code=0) output = self.cmd('check', '-v', '--repository-only', self.repository_location, exit_code=0)
self.assert_in('Starting repository check', output) self.assert_in('Starting repository check', output)
self.assert_not_in('Starting archive consistency check', output) self.assert_not_in('Starting archive consistency check', output)
self.assert_not_in('Checking segments', output)
output = self.cmd('check', '-v', '--archives-only', self.repository_location, exit_code=0) output = self.cmd('check', '-v', '--archives-only', self.repository_location, exit_code=0)
self.assert_not_in('Starting repository check', output) self.assert_not_in('Starting repository check', output)
self.assert_in('Starting archive consistency check', output) self.assert_in('Starting archive consistency check', output)
@ -1590,6 +1664,29 @@ class ArchiverCheckTestCase(ArchiverTestCaseBase):
self.cmd('check', self.repository_location, exit_code=0) self.cmd('check', self.repository_location, exit_code=0)
self.cmd('extract', '--dry-run', self.repository_location + '::archive1', exit_code=0) self.cmd('extract', '--dry-run', self.repository_location + '::archive1', exit_code=0)
def _test_verify_data(self, *init_args):
shutil.rmtree(self.repository_path)
self.cmd('init', self.repository_location, *init_args)
self.create_src_archive('archive1')
archive, repository = self.open_archive('archive1')
with repository:
for item in archive.iter_items():
if item[b'path'].endswith('testsuite/archiver.py'):
chunk = item[b'chunks'][-1]
data = repository.get(chunk.id) + b'1234'
repository.put(chunk.id, data)
break
repository.commit()
self.cmd('check', self.repository_location, exit_code=0)
output = self.cmd('check', '--verify-data', self.repository_location, exit_code=1)
assert bin_to_hex(chunk.id) + ', integrity error' in output
def test_verify_data(self):
self._test_verify_data('--encryption', 'repokey')
def test_verify_data_unencrypted(self):
self._test_verify_data('--encryption', 'none')
class RemoteArchiverTestCase(ArchiverTestCase): class RemoteArchiverTestCase(ArchiverTestCase):
prefix = '__testsuite__:' prefix = '__testsuite__:'

View File

@ -1,4 +0,0 @@
from ..logger import setup_logging
# Ensure that the loggers exist for all tests
setup_logging()

View File

@ -1,7 +1,8 @@
import hashlib import hashlib
import io
import logging
from time import mktime, strptime from time import mktime, strptime
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from io import StringIO
import os import os
import pytest import pytest
@ -11,7 +12,7 @@ import msgpack.fallback
import time import time
from ..helpers import Location, format_file_size, format_timedelta, make_path_safe, clean_lines, \ from ..helpers import Location, format_file_size, format_timedelta, make_path_safe, clean_lines, \
prune_within, prune_split, get_cache_dir, get_keys_dir, Statistics, is_slow_msgpack, \ prune_within, prune_split, get_cache_dir, get_keys_dir, is_slow_msgpack, \
yes, TRUISH, FALSISH, DEFAULTISH, \ yes, TRUISH, FALSISH, DEFAULTISH, \
StableDict, int_to_bigint, bigint_to_int, bin_to_hex, parse_timestamp, ChunkerParams, Chunk, \ StableDict, int_to_bigint, bigint_to_int, bin_to_hex, parse_timestamp, ChunkerParams, Chunk, \
ProgressIndicatorPercent, ProgressIndicatorEndless, load_excludes, parse_pattern, \ ProgressIndicatorPercent, ProgressIndicatorEndless, load_excludes, parse_pattern, \
@ -629,53 +630,6 @@ def test_get_keys_dir():
os.environ['BORG_KEYS_DIR'] = old_env os.environ['BORG_KEYS_DIR'] = old_env
@pytest.fixture()
def stats():
stats = Statistics()
stats.update(20, 10, unique=True)
return stats
def test_stats_basic(stats):
assert stats.osize == 20
assert stats.csize == stats.usize == 10
stats.update(20, 10, unique=False)
assert stats.osize == 40
assert stats.csize == 20
assert stats.usize == 10
def tests_stats_progress(stats, columns=80):
os.environ['COLUMNS'] = str(columns)
out = StringIO()
stats.show_progress(stream=out)
s = '20 B O 10 B C 10 B D 0 N '
buf = ' ' * (columns - len(s))
assert out.getvalue() == s + buf + "\r"
out = StringIO()
stats.update(10**3, 0, unique=False)
stats.show_progress(item={b'path': 'foo'}, final=False, stream=out)
s = '1.02 kB O 10 B C 10 B D 0 N foo'
buf = ' ' * (columns - len(s))
assert out.getvalue() == s + buf + "\r"
out = StringIO()
stats.show_progress(item={b'path': 'foo'*40}, final=False, stream=out)
s = '1.02 kB O 10 B C 10 B D 0 N foofoofoofoofoofoofoofo...oofoofoofoofoofoofoofoofoo'
buf = ' ' * (columns - len(s))
assert out.getvalue() == s + buf + "\r"
def test_stats_format(stats):
assert str(stats) == """\
Original size Compressed size Deduplicated size
This archive: 20 B 10 B 10 B"""
s = "{0.osize_fmt}".format(stats)
assert s == "20 B"
# kind of redundant, but id is variable so we can't match reliably
assert repr(stats) == '<Statistics object at {:#x} (20, 10, 10)>'.format(id(stats))
def test_file_size(): def test_file_size():
"""test the size formatting routines""" """test the size formatting routines"""
si_size_map = { si_size_map = {
@ -826,7 +780,7 @@ def test_yes_output(capfd):
def test_progress_percentage_multiline(capfd): def test_progress_percentage_multiline(capfd):
pi = ProgressIndicatorPercent(1000, step=5, start=0, same_line=False, msg="%3.0f%%", file=sys.stderr) pi = ProgressIndicatorPercent(1000, step=5, start=0, same_line=False, msg="%3.0f%%")
pi.show(0) pi.show(0)
out, err = capfd.readouterr() out, err = capfd.readouterr()
assert err == ' 0%\n' assert err == ' 0%\n'
@ -842,13 +796,14 @@ def test_progress_percentage_multiline(capfd):
def test_progress_percentage_sameline(capfd): def test_progress_percentage_sameline(capfd):
pi = ProgressIndicatorPercent(1000, step=5, start=0, same_line=True, msg="%3.0f%%", file=sys.stderr) pi = ProgressIndicatorPercent(1000, step=5, start=0, same_line=True, msg="%3.0f%%")
pi.show(0) pi.show(0)
out, err = capfd.readouterr() out, err = capfd.readouterr()
assert err == ' 0%\r' assert err == ' 0%\r'
pi.show(420) pi.show(420)
pi.show(680)
out, err = capfd.readouterr() out, err = capfd.readouterr()
assert err == ' 42%\r' assert err == ' 42%\r 68%\r'
pi.show(1000) pi.show(1000)
out, err = capfd.readouterr() out, err = capfd.readouterr()
assert err == '100%\r' assert err == '100%\r'
@ -858,7 +813,7 @@ def test_progress_percentage_sameline(capfd):
def test_progress_percentage_step(capfd): def test_progress_percentage_step(capfd):
pi = ProgressIndicatorPercent(100, step=2, start=0, same_line=False, msg="%3.0f%%", file=sys.stderr) pi = ProgressIndicatorPercent(100, step=2, start=0, same_line=False, msg="%3.0f%%")
pi.show() pi.show()
out, err = capfd.readouterr() out, err = capfd.readouterr()
assert err == ' 0%\n' assert err == ' 0%\n'
@ -870,6 +825,21 @@ def test_progress_percentage_step(capfd):
assert err == ' 2%\n' assert err == ' 2%\n'
def test_progress_percentage_quiet(capfd):
logging.getLogger('borg.output.progress').setLevel(logging.WARN)
pi = ProgressIndicatorPercent(1000, step=5, start=0, same_line=False, msg="%3.0f%%")
pi.show(0)
out, err = capfd.readouterr()
assert err == ''
pi.show(1000)
out, err = capfd.readouterr()
assert err == ''
pi.finish()
out, err = capfd.readouterr()
assert err == ''
def test_progress_endless(capfd): def test_progress_endless(capfd):
pi = ProgressIndicatorEndless(step=1, file=sys.stderr) pi = ProgressIndicatorEndless(step=1, file=sys.stderr)
pi.show() pi.show()

147
src/borg/testsuite/item.py Normal file
View File

@ -0,0 +1,147 @@
import pytest
from ..item import Item
from ..helpers import StableDict
def test_item_empty():
item = Item()
assert item.as_dict() == {}
assert 'path' not in item
with pytest.raises(ValueError):
'invalid-key' in item
with pytest.raises(TypeError):
b'path' in item
with pytest.raises(TypeError):
42 in item
assert item.get('mode') is None
assert item.get('mode', 0o666) == 0o666
with pytest.raises(ValueError):
item.get('invalid-key')
with pytest.raises(TypeError):
item.get(b'mode')
with pytest.raises(TypeError):
item.get(42)
with pytest.raises(AttributeError):
item.path
with pytest.raises(AttributeError):
del item.path
def test_item_from_dict():
# does not matter whether we get str or bytes keys
item = Item({b'path': b'/a/b/c', b'mode': 0o666})
assert item.path == '/a/b/c'
assert item.mode == 0o666
assert 'path' in item
# does not matter whether we get str or bytes keys
item = Item({'path': b'/a/b/c', 'mode': 0o666})
assert item.path == '/a/b/c'
assert item.mode == 0o666
assert 'mode' in item
# invalid - no dict
with pytest.raises(TypeError):
Item(42)
# invalid - no bytes/str key
with pytest.raises(TypeError):
Item({42: 23})
# invalid - unknown key
with pytest.raises(ValueError):
Item({'foobar': 'baz'})
def test_item_from_kw():
item = Item(path=b'/a/b/c', mode=0o666)
assert item.path == '/a/b/c'
assert item.mode == 0o666
def test_item_int_property():
item = Item()
item.mode = 0o666
assert item.mode == 0o666
assert item.as_dict() == {'mode': 0o666}
del item.mode
assert item.as_dict() == {}
with pytest.raises(TypeError):
item.mode = "invalid"
def test_item_bigint_property():
item = Item()
small, big = 42, 2 ** 65
item.atime = small
assert item.atime == small
assert item.as_dict() == {'atime': small}
item.atime = big
assert item.atime == big
assert item.as_dict() == {'atime': b'\0' * 8 + b'\x02'}
def test_item_user_group_none():
item = Item()
item.user = None
assert item.user is None
item.group = None
assert item.group is None
def test_item_se_str_property():
# start simple
item = Item()
item.path = '/a/b/c'
assert item.path == '/a/b/c'
assert item.as_dict() == {'path': b'/a/b/c'}
del item.path
assert item.as_dict() == {}
with pytest.raises(TypeError):
item.path = 42
# non-utf-8 path, needing surrogate-escaping for latin-1 u-umlaut
item = Item({'path': b'/a/\xfc/c'})
assert item.path == '/a/\udcfc/c' # getting a surrogate-escaped representation
assert item.as_dict() == {'path': b'/a/\xfc/c'}
del item.path
assert 'path' not in item
item.path = '/a/\udcfc/c' # setting using a surrogate-escaped representation
assert item.as_dict() == {'path': b'/a/\xfc/c'}
def test_item_list_property():
item = Item()
item.chunks = []
assert item.chunks == []
item.chunks.append(0)
assert item.chunks == [0]
item.chunks.append(1)
assert item.chunks == [0, 1]
assert item.as_dict() == {'chunks': [0, 1]}
def test_item_dict_property():
item = Item()
item.xattrs = StableDict()
assert item.xattrs == StableDict()
item.xattrs['foo'] = 'bar'
assert item.xattrs['foo'] == 'bar'
item.xattrs['bar'] = 'baz'
assert item.xattrs == StableDict({'foo': 'bar', 'bar': 'baz'})
assert item.as_dict() == {'xattrs': {'foo': 'bar', 'bar': 'baz'}}
def test_unknown_property():
# we do not want the user to be able to set unknown attributes -
# they won't get into the .as_dict() result dictionary.
# also they might be just typos of known attributes.
item = Item()
with pytest.raises(AttributeError):
item.unknown_attribute = None

View File

@ -1,17 +1,24 @@
import getpass
import os import os
import re import re
import shutil import shutil
import tempfile import tempfile
from binascii import hexlify, unhexlify from binascii import hexlify, unhexlify
import pytest
from ..crypto import bytes_to_long, num_aes_blocks from ..crypto import bytes_to_long, num_aes_blocks
from ..key import PlaintextKey, PassphraseKey, KeyfileKey from ..key import PlaintextKey, PassphraseKey, KeyfileKey, Passphrase, PasswordRetriesExceeded, bin_to_hex
from ..helpers import Location, Chunk, bin_to_hex from ..helpers import Location, Chunk, IntegrityError
from . import BaseTestCase, environment_variable
class KeyTestCase(BaseTestCase): @pytest.fixture(autouse=True)
def clean_env(monkeypatch):
# Workaround for some tests (testsuite/archiver) polluting the environment
monkeypatch.delenv('BORG_PASSPHRASE', False)
class TestKey:
class MockArgs: class MockArgs:
location = Location(tempfile.mkstemp()[1]) location = Location(tempfile.mkstemp()[1])
@ -31,14 +38,10 @@ class KeyTestCase(BaseTestCase):
""")) """))
keyfile2_id = unhexlify('c3fbf14bc001ebcc3cd86e696c13482ed071740927cd7cbe1b01b4bfcee49314') keyfile2_id = unhexlify('c3fbf14bc001ebcc3cd86e696c13482ed071740927cd7cbe1b01b4bfcee49314')
def setUp(self): @pytest.fixture
self.tmppath = tempfile.mkdtemp() def keys_dir(self, request, monkeypatch, tmpdir):
os.environ['BORG_KEYS_DIR'] = self.tmppath monkeypatch.setenv('BORG_KEYS_DIR', tmpdir)
self.tmppath2 = tempfile.mkdtemp() return tmpdir
def tearDown(self):
shutil.rmtree(self.tmppath)
shutil.rmtree(self.tmppath2)
class MockRepository: class MockRepository:
class _Location: class _Location:
@ -51,78 +54,144 @@ class KeyTestCase(BaseTestCase):
def test_plaintext(self): def test_plaintext(self):
key = PlaintextKey.create(None, None) key = PlaintextKey.create(None, None)
chunk = Chunk(b'foo') chunk = Chunk(b'foo')
self.assert_equal(hexlify(key.id_hash(chunk.data)), b'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae') assert hexlify(key.id_hash(chunk.data)) == b'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'
self.assert_equal(chunk, key.decrypt(key.id_hash(chunk.data), key.encrypt(chunk))) assert chunk == key.decrypt(key.id_hash(chunk.data), key.encrypt(chunk))
def test_keyfile(self): def test_keyfile(self, monkeypatch, keys_dir):
os.environ['BORG_PASSPHRASE'] = 'test' monkeypatch.setenv('BORG_PASSPHRASE', 'test')
key = KeyfileKey.create(self.MockRepository(), self.MockArgs()) key = KeyfileKey.create(self.MockRepository(), self.MockArgs())
self.assert_equal(bytes_to_long(key.enc_cipher.iv, 8), 0) assert bytes_to_long(key.enc_cipher.iv, 8) == 0
manifest = key.encrypt(Chunk(b'XXX')) manifest = key.encrypt(Chunk(b'XXX'))
self.assert_equal(key.extract_nonce(manifest), 0) assert key.extract_nonce(manifest) == 0
manifest2 = key.encrypt(Chunk(b'XXX')) manifest2 = key.encrypt(Chunk(b'XXX'))
self.assert_not_equal(manifest, manifest2) assert manifest != manifest2
self.assert_equal(key.decrypt(None, manifest), key.decrypt(None, manifest2)) assert key.decrypt(None, manifest) == key.decrypt(None, manifest2)
self.assert_equal(key.extract_nonce(manifest2), 1) assert key.extract_nonce(manifest2) == 1
iv = key.extract_nonce(manifest) iv = key.extract_nonce(manifest)
key2 = KeyfileKey.detect(self.MockRepository(), manifest) key2 = KeyfileKey.detect(self.MockRepository(), manifest)
self.assert_equal(bytes_to_long(key2.enc_cipher.iv, 8), iv + num_aes_blocks(len(manifest) - KeyfileKey.PAYLOAD_OVERHEAD)) assert bytes_to_long(key2.enc_cipher.iv, 8) == iv + num_aes_blocks(len(manifest) - KeyfileKey.PAYLOAD_OVERHEAD)
# Key data sanity check # Key data sanity check
self.assert_equal(len(set([key2.id_key, key2.enc_key, key2.enc_hmac_key])), 3) assert len({key2.id_key, key2.enc_key, key2.enc_hmac_key}) == 3
self.assert_equal(key2.chunk_seed == 0, False) assert key2.chunk_seed != 0
chunk = Chunk(b'foo') chunk = Chunk(b'foo')
self.assert_equal(chunk, key2.decrypt(key.id_hash(chunk.data), key.encrypt(chunk))) assert chunk == key2.decrypt(key.id_hash(chunk.data), key.encrypt(chunk))
def test_keyfile_kfenv(self): def test_keyfile_kfenv(self, tmpdir, monkeypatch):
keyfile = os.path.join(self.tmppath2, 'keyfile') keyfile = tmpdir.join('keyfile')
with environment_variable(BORG_KEY_FILE=keyfile, BORG_PASSPHRASE='testkf'): monkeypatch.setenv('BORG_KEY_FILE', str(keyfile))
assert not os.path.exists(keyfile) monkeypatch.setenv('BORG_PASSPHRASE', 'testkf')
key = KeyfileKey.create(self.MockRepository(), self.MockArgs()) assert not keyfile.exists()
assert os.path.exists(keyfile) key = KeyfileKey.create(self.MockRepository(), self.MockArgs())
chunk = Chunk(b'XXX') assert keyfile.exists()
chunk_id = key.id_hash(chunk.data) chunk = Chunk(b'XXX')
chunk_cdata = key.encrypt(chunk) chunk_id = key.id_hash(chunk.data)
key = KeyfileKey.detect(self.MockRepository(), chunk_cdata) chunk_cdata = key.encrypt(chunk)
self.assert_equal(chunk, key.decrypt(chunk_id, chunk_cdata)) key = KeyfileKey.detect(self.MockRepository(), chunk_cdata)
os.unlink(keyfile) assert chunk == key.decrypt(chunk_id, chunk_cdata)
self.assert_raises(FileNotFoundError, KeyfileKey.detect, self.MockRepository(), chunk_cdata) keyfile.remove()
with pytest.raises(FileNotFoundError):
KeyfileKey.detect(self.MockRepository(), chunk_cdata)
def test_keyfile2(self): def test_keyfile2(self, monkeypatch, keys_dir):
with open(os.path.join(os.environ['BORG_KEYS_DIR'], 'keyfile'), 'w') as fd: with keys_dir.join('keyfile').open('w') as fd:
fd.write(self.keyfile2_key_file) fd.write(self.keyfile2_key_file)
os.environ['BORG_PASSPHRASE'] = 'passphrase' monkeypatch.setenv('BORG_PASSPHRASE', 'passphrase')
key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata) key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata)
self.assert_equal(key.decrypt(self.keyfile2_id, self.keyfile2_cdata).data, b'payload') assert key.decrypt(self.keyfile2_id, self.keyfile2_cdata).data == b'payload'
def test_keyfile2_kfenv(self): def test_keyfile2_kfenv(self, tmpdir, monkeypatch):
keyfile = os.path.join(self.tmppath2, 'keyfile') keyfile = tmpdir.join('keyfile')
with open(keyfile, 'w') as fd: with keyfile.open('w') as fd:
fd.write(self.keyfile2_key_file) fd.write(self.keyfile2_key_file)
with environment_variable(BORG_KEY_FILE=keyfile, BORG_PASSPHRASE='passphrase'): monkeypatch.setenv('BORG_KEY_FILE', str(keyfile))
key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata) monkeypatch.setenv('BORG_PASSPHRASE', 'passphrase')
self.assert_equal(key.decrypt(self.keyfile2_id, self.keyfile2_cdata).data, b'payload') key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata)
assert key.decrypt(self.keyfile2_id, self.keyfile2_cdata).data == b'payload'
def test_passphrase(self): def test_passphrase(self, keys_dir, monkeypatch):
os.environ['BORG_PASSPHRASE'] = 'test' monkeypatch.setenv('BORG_PASSPHRASE', 'test')
key = PassphraseKey.create(self.MockRepository(), None) key = PassphraseKey.create(self.MockRepository(), None)
self.assert_equal(bytes_to_long(key.enc_cipher.iv, 8), 0) assert bytes_to_long(key.enc_cipher.iv, 8) == 0
self.assert_equal(hexlify(key.id_key), b'793b0717f9d8fb01c751a487e9b827897ceea62409870600013fbc6b4d8d7ca6') assert hexlify(key.id_key) == b'793b0717f9d8fb01c751a487e9b827897ceea62409870600013fbc6b4d8d7ca6'
self.assert_equal(hexlify(key.enc_hmac_key), b'b885a05d329a086627412a6142aaeb9f6c54ab7950f996dd65587251f6bc0901') assert hexlify(key.enc_hmac_key) == b'b885a05d329a086627412a6142aaeb9f6c54ab7950f996dd65587251f6bc0901'
self.assert_equal(hexlify(key.enc_key), b'2ff3654c6daf7381dbbe718d2b20b4f1ea1e34caa6cc65f6bb3ac376b93fed2a') assert hexlify(key.enc_key) == b'2ff3654c6daf7381dbbe718d2b20b4f1ea1e34caa6cc65f6bb3ac376b93fed2a'
self.assert_equal(key.chunk_seed, -775740477) assert key.chunk_seed == -775740477
manifest = key.encrypt(Chunk(b'XXX')) manifest = key.encrypt(Chunk(b'XXX'))
self.assert_equal(key.extract_nonce(manifest), 0) assert key.extract_nonce(manifest) == 0
manifest2 = key.encrypt(Chunk(b'XXX')) manifest2 = key.encrypt(Chunk(b'XXX'))
self.assert_not_equal(manifest, manifest2) assert manifest != manifest2
self.assert_equal(key.decrypt(None, manifest), key.decrypt(None, manifest2)) assert key.decrypt(None, manifest) == key.decrypt(None, manifest2)
self.assert_equal(key.extract_nonce(manifest2), 1) assert key.extract_nonce(manifest2) == 1
iv = key.extract_nonce(manifest) iv = key.extract_nonce(manifest)
key2 = PassphraseKey.detect(self.MockRepository(), manifest) key2 = PassphraseKey.detect(self.MockRepository(), manifest)
self.assert_equal(bytes_to_long(key2.enc_cipher.iv, 8), iv + num_aes_blocks(len(manifest) - PassphraseKey.PAYLOAD_OVERHEAD)) assert bytes_to_long(key2.enc_cipher.iv, 8) == iv + num_aes_blocks(len(manifest) - PassphraseKey.PAYLOAD_OVERHEAD)
self.assert_equal(key.id_key, key2.id_key) assert key.id_key == key2.id_key
self.assert_equal(key.enc_hmac_key, key2.enc_hmac_key) assert key.enc_hmac_key == key2.enc_hmac_key
self.assert_equal(key.enc_key, key2.enc_key) assert key.enc_key == key2.enc_key
self.assert_equal(key.chunk_seed, key2.chunk_seed) assert key.chunk_seed == key2.chunk_seed
chunk = Chunk(b'foo') chunk = Chunk(b'foo')
self.assert_equal(hexlify(key.id_hash(chunk.data)), b'818217cf07d37efad3860766dcdf1d21e401650fed2d76ed1d797d3aae925990') assert hexlify(key.id_hash(chunk.data)) == b'818217cf07d37efad3860766dcdf1d21e401650fed2d76ed1d797d3aae925990'
self.assert_equal(chunk, key2.decrypt(key2.id_hash(chunk.data), key.encrypt(chunk))) assert chunk == key2.decrypt(key2.id_hash(chunk.data), key.encrypt(chunk))
def _corrupt_byte(self, key, data, offset):
data = bytearray(data)
data[offset] += 1
with pytest.raises(IntegrityError):
key.decrypt("", data)
def test_decrypt_integrity(self, monkeypatch, keys_dir):
with keys_dir.join('keyfile').open('w') as fd:
fd.write(self.keyfile2_key_file)
monkeypatch.setenv('BORG_PASSPHRASE', 'passphrase')
key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata)
data = self.keyfile2_cdata
for i in range(len(data)):
self._corrupt_byte(key, data, i)
with pytest.raises(IntegrityError):
data = bytearray(self.keyfile2_cdata)
id = bytearray(key.id_hash(data)) # corrupt chunk id
id[12] = 0
key.decrypt(id, data)
class TestPassphrase:
def test_passphrase_new_verification(self, capsys, monkeypatch):
monkeypatch.setattr(getpass, 'getpass', lambda prompt: "12aöäü")
monkeypatch.setenv('BORG_DISPLAY_PASSPHRASE', 'no')
Passphrase.new()
out, err = capsys.readouterr()
assert "12" not in out
assert "12" not in err
monkeypatch.setenv('BORG_DISPLAY_PASSPHRASE', 'yes')
passphrase = Passphrase.new()
out, err = capsys.readouterr()
assert "313261c3b6c3a4c3bc" not in out
assert "313261c3b6c3a4c3bc" in err
assert passphrase == "12aöäü"
monkeypatch.setattr(getpass, 'getpass', lambda prompt: "1234/@=")
Passphrase.new()
out, err = capsys.readouterr()
assert "1234/@=" not in out
assert "1234/@=" in err
def test_passphrase_new_empty(self, capsys, monkeypatch):
monkeypatch.delenv('BORG_PASSPHRASE', False)
monkeypatch.setattr(getpass, 'getpass', lambda prompt: "")
with pytest.raises(PasswordRetriesExceeded):
Passphrase.new(allow_empty=False)
out, err = capsys.readouterr()
assert "must not be blank" in err
def test_passphrase_new_retries(self, monkeypatch):
monkeypatch.delenv('BORG_PASSPHRASE', False)
ascending_numbers = iter(range(20))
monkeypatch.setattr(getpass, 'getpass', lambda prompt: str(next(ascending_numbers)))
with pytest.raises(PasswordRetriesExceeded):
Passphrase.new()
def test_passphrase_repr(self):
assert "secret" not in repr(Passphrase("secret"))

View File

@ -4,7 +4,7 @@ import sys
import tempfile import tempfile
import unittest import unittest
from ..platform import acl_get, acl_set from ..platform import acl_get, acl_set, swidth
from . import BaseTestCase from . import BaseTestCase
@ -138,3 +138,16 @@ class PlatformDarwinTestCase(BaseTestCase):
self.set_acl(file2.name, b'!#acl 1\ngroup:ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000000:staff:0:allow:read\nuser:FFFFEEEE-DDDD-CCCC-BBBB-AAAA00000000:root:0:allow:read\n', numeric_owner=True) self.set_acl(file2.name, b'!#acl 1\ngroup:ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000000:staff:0:allow:read\nuser:FFFFEEEE-DDDD-CCCC-BBBB-AAAA00000000:root:0:allow:read\n', numeric_owner=True)
self.assert_in(b'group:ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000000:wheel:0:allow:read', self.get_acl(file2.name)[b'acl_extended']) self.assert_in(b'group:ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000000:wheel:0:allow:read', self.get_acl(file2.name)[b'acl_extended'])
self.assert_in(b'group:ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000000::0:allow:read', self.get_acl(file2.name, numeric_owner=True)[b'acl_extended']) self.assert_in(b'group:ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000000::0:allow:read', self.get_acl(file2.name, numeric_owner=True)[b'acl_extended'])
@unittest.skipUnless(sys.platform.startswith(('linux', 'freebsd', 'darwin')), 'POSIX only tests')
class PlatformPosixTestCase(BaseTestCase):
def test_swidth_ascii(self):
self.assert_equal(swidth("borg"), 4)
def test_swidth_cjk(self):
self.assert_equal(swidth("バックアップ"), 6 * 2)
def test_swidth_mixed(self):
self.assert_equal(swidth("borgバックアップ"), 4 + 6 * 2)

View File

@ -1,3 +1,5 @@
import io
import logging
import os import os
import shutil import shutil
import sys import sys
@ -7,8 +9,8 @@ from unittest.mock import patch
from ..hashindex import NSIndex from ..hashindex import NSIndex
from ..helpers import Location, IntegrityError from ..helpers import Location, IntegrityError
from ..locking import UpgradableLock, LockFailed from ..locking import UpgradableLock, LockFailed
from ..remote import RemoteRepository, InvalidRPCMethod from ..remote import RemoteRepository, InvalidRPCMethod, ConnectionClosedWithHint
from ..repository import Repository from ..repository import Repository, LoggedIO
from . import BaseTestCase from . import BaseTestCase
@ -192,6 +194,13 @@ class RepositoryCommitTestCase(RepositoryTestCaseBase):
self.assert_equal(self.repository.check(), True) self.assert_equal(self.repository.check(), True)
self.assert_equal(len(self.repository), 3) self.assert_equal(len(self.repository), 3)
def test_ignores_commit_tag_in_data(self):
self.repository.put(b'0' * 32, LoggedIO.COMMIT)
self.reopen()
with self.repository:
io = self.repository.io
assert not io.is_committed_segment(io.get_latest_segment())
class RepositoryAppendOnlyTestCase(RepositoryTestCaseBase): class RepositoryAppendOnlyTestCase(RepositoryTestCaseBase):
def test_destroy_append_only(self): def test_destroy_append_only(self):
@ -268,7 +277,7 @@ class RepositoryCheckTestCase(RepositoryTestCaseBase):
return set(int(key) for key in self.repository.list()) return set(int(key) for key in self.repository.list())
def test_repair_corrupted_segment(self): def test_repair_corrupted_segment(self):
self.add_objects([[1, 2, 3], [4, 5, 6]]) self.add_objects([[1, 2, 3], [4, 5], [6]])
self.assert_equal(set([1, 2, 3, 4, 5, 6]), self.list_objects()) self.assert_equal(set([1, 2, 3, 4, 5, 6]), self.list_objects())
self.check(status=True) self.check(status=True)
self.corrupt_object(5) self.corrupt_object(5)
@ -389,3 +398,83 @@ class RemoteRepositoryCheckTestCase(RepositoryCheckTestCase):
def test_crash_before_compact(self): def test_crash_before_compact(self):
# skip this test, we can't mock-patch a Repository class in another process! # skip this test, we can't mock-patch a Repository class in another process!
pass pass
class RemoteRepositoryLoggingStub(RemoteRepository):
""" run a remote command that just prints a logging-formatted message to
stderr, and stub out enough of RemoteRepository to avoid the resulting
exceptions """
def __init__(self, *args, **kw):
self.msg = kw.pop('msg')
super().__init__(*args, **kw)
def borg_cmd(self, cmd, testing):
return [sys.executable, '-c', 'import sys; print("{}", file=sys.stderr)'.format(self.msg), ]
def __del__(self):
# clean up from exception without triggering assert
if self.p:
self.close()
class RemoteRepositoryLoggerTestCase(RepositoryTestCaseBase):
def setUp(self):
self.location = Location('__testsuite__:/doesntexist/repo')
self.stream = io.StringIO()
self.handler = logging.StreamHandler(self.stream)
logging.getLogger().handlers[:] = [self.handler]
logging.getLogger('borg.repository').handlers[:] = []
logging.getLogger('borg.repository.foo').handlers[:] = []
def tearDown(self):
pass
def create_repository(self, msg):
try:
RemoteRepositoryLoggingStub(self.location, msg=msg)
except ConnectionClosedWithHint:
# stub is dumb, so this exception expected
pass
def test_old_format_messages(self):
self.handler.setLevel(logging.DEBUG)
logging.getLogger().setLevel(logging.DEBUG)
self.create_repository("$LOG INFO Remote: old format message")
self.assert_equal(self.stream.getvalue(), 'Remote: old format message\n')
def test_new_format_messages(self):
self.handler.setLevel(logging.DEBUG)
logging.getLogger().setLevel(logging.DEBUG)
self.create_repository("$LOG INFO borg.repository Remote: new format message")
self.assert_equal(self.stream.getvalue(), 'Remote: new format message\n')
def test_remote_messages_screened(self):
# default borg config for root logger
self.handler.setLevel(logging.WARNING)
logging.getLogger().setLevel(logging.WARNING)
self.create_repository("$LOG INFO borg.repository Remote: new format info message")
self.assert_equal(self.stream.getvalue(), '')
def test_info_to_correct_local_child(self):
logging.getLogger('borg.repository').setLevel(logging.INFO)
logging.getLogger('borg.repository.foo').setLevel(logging.INFO)
# default borg config for root logger
self.handler.setLevel(logging.WARNING)
logging.getLogger().setLevel(logging.WARNING)
child_stream = io.StringIO()
child_handler = logging.StreamHandler(child_stream)
child_handler.setLevel(logging.INFO)
logging.getLogger('borg.repository').handlers[:] = [child_handler]
foo_stream = io.StringIO()
foo_handler = logging.StreamHandler(foo_stream)
foo_handler.setLevel(logging.INFO)
logging.getLogger('borg.repository.foo').handlers[:] = [foo_handler]
self.create_repository("$LOG INFO borg.repository Remote: new format child message")
self.assert_equal(foo_stream.getvalue(), '')
self.assert_equal(child_stream.getvalue(), 'Remote: new format child message\n')
self.assert_equal(self.stream.getvalue(), '')