Browse Source

Merge branch '1.0-maint'

Thomas Waldmann 9 years ago
parent
commit
f363ddd7ca

+ 1 - 0
.gitignore

@@ -24,3 +24,4 @@ borg.dist/
 borg.exe
 borg.exe
 .coverage
 .coverage
 .vagrant
 .vagrant
+.eggs

+ 25 - 16
docs/usage.rst

@@ -738,32 +738,34 @@ For more details, see :ref:`chunker_details`.
 --read-special
 --read-special
 ~~~~~~~~~~~~~~
 ~~~~~~~~~~~~~~
 
 
-The option ``--read-special`` is not intended for normal, filesystem-level (full or
-partly-recursive) backups. You only give this option if you want to do something
-rather ... special -- and if you have hand-picked some files that you want to treat
-that way.
+The --read-special option is special - you do not want to use it for normal
+full-filesystem backups, but rather after carefully picking some targets for it.
 
 
-``borg create --read-special`` will open all files without doing any special
-treatment according to the file type (the only exception here are directories:
-they will be recursed into). Just imagine what happens if you do ``cat
-filename`` --- the content you will see there is what borg will backup for that
-filename.
+The option ``--read-special`` triggers special treatment for block and char
+device files as well as FIFOs. Instead of storing them as such a device (or
+FIFO), they will get opened, their content will be read and in the backup
+archive they will show up like a regular file.
 
 
-So, for example, symlinks will be followed, block device content will be read,
-named pipes / UNIX domain sockets will be read.
+Symlinks will also get special treatment if (and only if) they point to such
+a special file: instead of storing them as a symlink, the target special file
+will get processed as described above.
 
 
-You need to be careful with what you give as filename when using ``--read-special``,
-e.g. if you give ``/dev/zero``, your backup will never terminate.
+One intended use case of this is backing up the contents of one or multiple
+block devices, like e.g. LVM snapshots or inactive LVs or disk partitions.
 
 
-The given files' metadata is saved as it would be saved without
-``--read-special`` (e.g. its name, its size [might be 0], its mode, etc.) -- but
-additionally, also the content read from it will be saved for it.
+You need to be careful about what you include when using ``--read-special``,
+e.g. if you include ``/dev/zero``, your backup will never terminate.
 
 
 Restoring such files' content is currently only supported one at a time via
 Restoring such files' content is currently only supported one at a time via
 ``--stdout`` option (and you have to redirect stdout to where ever it shall go,
 ``--stdout`` option (and you have to redirect stdout to where ever it shall go,
 maybe directly into an existing device file of your choice or indirectly via
 maybe directly into an existing device file of your choice or indirectly via
 ``dd``).
 ``dd``).
 
 
+To some extent, mounting a backup archive with the backups of special files
+via ``borg mount`` and then loop-mounting the image files from inside the mount
+point will work. If you plan to access a lot of data in there, it likely will
+scale and perform better if you do not work via the FUSE mount.
+
 Example
 Example
 +++++++
 +++++++
 
 
@@ -817,6 +819,13 @@ To activate append-only mode, edit the repository ``config`` file and add a line
 In append-only mode Borg will create a transaction log in the ``transactions`` file,
 In append-only mode Borg will create a transaction log in the ``transactions`` file,
 where each line is a transaction and a UTC timestamp.
 where each line is a transaction and a UTC timestamp.
 
 
+In addition, ``borg serve`` can act as if a repository is in append-only mode with
+its option ``--append-only``. This can be very useful for fine-tuning access control
+in ``.ssh/authorized_keys`` ::
+
+    command="borg serve --append-only ..." ssh-rsa <key used for not-always-trustable backup clients>
+    command="borg serve ..." ssh-rsa <key used for backup management>
+
 Example
 Example
 +++++++
 +++++++
 
 

+ 1 - 0
docs/usage/create.rst.inc

@@ -87,3 +87,4 @@ potentially decreases reliability of change detection, while avoiding always rea
 all files on these file systems.
 all files on these file systems.
 
 
 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.
+See the output of the "borg help placeholders" command for more help on placeholders.

+ 79 - 40
docs/usage/help.rst.inc

@@ -1,3 +1,41 @@
+.. _borg_placeholders:
+
+borg help placeholders
+~~~~~~~~~~~~~~~~~~~~~~
+::
+
+
+Repository (or Archive) URLs and --prefix values support these placeholders:
+
+{hostname}
+
+    The (short) hostname of the machine.
+
+{fqdn}
+
+    The full name of the machine.
+
+{now}
+
+    The current local date and time.
+
+{utcnow}
+
+    The current UTC date and time.
+
+{user}
+
+    The user name (or UID, if no name is available) of the user running borg.
+
+{pid}
+
+    The current process ID.
+
+Examples::
+
+    borg create /path/to/repo::{hostname}-{user}-{utcnow} ...
+    borg create /path/to/repo::{hostname}-{now:%Y-%m-%d_%H:%M:%S} ...
+    borg prune --prefix '{hostname}-' ...
 .. _borg_patterns:
 .. _borg_patterns:
 
 
 borg help patterns
 borg help patterns
@@ -6,26 +44,27 @@ borg help patterns
 
 
 
 
 Exclusion patterns support four separate styles, fnmatch, shell, regular
 Exclusion patterns support four separate styles, fnmatch, shell, regular
-expressions and path prefixes. If followed by a colon (':') the first two
-characters of a pattern are used as a style selector. Explicit style
-selection is necessary when a non-default style is desired or when the
-desired pattern starts with two alphanumeric characters followed by a colon
-(i.e. `aa:something/*`).
+expressions and path prefixes. By default, fnmatch is used. If followed
+by a colon (':') the first two characters of a pattern are used as a
+style selector. Explicit style selection is necessary when a
+non-default style is desired or when the desired pattern starts with
+two alphanumeric characters followed by a colon (i.e. `aa:something/*`).
 
 
 `Fnmatch <https://docs.python.org/3/library/fnmatch.html>`_, selector `fm:`
 `Fnmatch <https://docs.python.org/3/library/fnmatch.html>`_, selector `fm:`
 
 
-    These patterns use a variant of shell pattern syntax, with '*' matching
-    any number of characters, '?' matching any single character, '[...]'
-    matching any single character specified, including ranges, and '[!...]'
-    matching any character not specified. For the purpose of these patterns,
-    the path separator ('\' for Windows and '/' on other systems) is not
-    treated specially. Wrap meta-characters in brackets for a literal match
-    (i.e. `[?]` to match the literal character `?`). For a path to match
-    a pattern, it must completely match from start to end, or must match from
-    the start to just before a path separator. Except for the root path,
-    paths will never end in the path separator when matching is attempted.
-    Thus, if a given pattern ends in a path separator, a '*' is appended
-    before matching is attempted.
+    This is the default style.  These patterns use a variant of shell
+    pattern syntax, with '*' matching any number of characters, '?'
+    matching any single character, '[...]' matching any single
+    character specified, including ranges, and '[!...]' matching any
+    character not specified. For the purpose of these patterns, the
+    path separator ('\' for Windows and '/' on other systems) is not
+    treated specially. Wrap meta-characters in brackets for a literal
+    match (i.e. `[?]` to match the literal character `?`). For a path
+    to match a pattern, it must completely match from start to end, or
+    must match from the start to just before a path separator. Except
+    for the root path, paths will never end in the path separator when
+    matching is attempted.  Thus, if a given pattern ends in a path
+    separator, a '*' is appended before matching is attempted.
 
 
 Shell-style patterns, selector `sh:`
 Shell-style patterns, selector `sh:`
 
 
@@ -61,32 +100,32 @@ selector prefix is also supported for patterns loaded from a file. Due to
 whitespace removal paths with whitespace at the beginning or end can only be
 whitespace removal paths with whitespace at the beginning or end can only be
 excluded using regular expressions.
 excluded using regular expressions.
 
 
-Examples:
+Examples::
 
 
-# Exclude '/home/user/file.o' but not '/home/user/file.odt':
-$ borg create -e '*.o' backup /
+    # Exclude '/home/user/file.o' but not '/home/user/file.odt':
+    $ borg create -e '*.o' backup /
 
 
-# Exclude '/home/user/junk' and '/home/user/subdir/junk' but
-# not '/home/user/importantjunk' or '/etc/junk':
-$ borg create -e '/home/*/junk' backup /
+    # Exclude '/home/user/junk' and '/home/user/subdir/junk' but
+    # not '/home/user/importantjunk' or '/etc/junk':
+    $ borg create -e '/home/*/junk' backup /
 
 
-# Exclude the contents of '/home/user/cache' but not the directory itself:
-$ borg create -e /home/user/cache/ backup /
+    # Exclude the contents of '/home/user/cache' but not the directory itself:
+    $ borg create -e /home/user/cache/ backup /
 
 
-# The file '/home/user/cache/important' is *not* backed up:
-$ borg create -e /home/user/cache/ backup / /home/user/cache/important
+    # The file '/home/user/cache/important' is *not* backed up:
+    $ borg create -e /home/user/cache/ backup / /home/user/cache/important
 
 
-# The contents of directories in '/home' are not backed up when their name
-# ends in '.tmp'
-$ borg create --exclude 're:^/home/[^/]+\.tmp/' backup /
+    # The contents of directories in '/home' are not backed up when their name
+    # ends in '.tmp'
+    $ borg create --exclude 're:^/home/[^/]+\.tmp/' backup /
 
 
-# Load exclusions from file
-$ cat >exclude.txt <<EOF
-# Comment line
-/home/*/junk
-*.tmp
-fm:aa:something/*
-re:^/home/[^/]\.tmp/
-sh:/home/*/.thumbnails
-EOF
-$ borg create --exclude-from exclude.txt backup /
+    # Load exclusions from file
+    $ cat >exclude.txt <<EOF
+    # Comment line
+    /home/*/junk
+    *.tmp
+    fm:aa:something/*
+    re:^/home/[^/]\.tmp/
+    sh:/home/*/.thumbnails
+    EOF
+    $ borg create --exclude-from exclude.txt backup /

+ 2 - 2
docs/usage/prune.rst.inc

@@ -40,7 +40,7 @@ optional arguments
 Description
 Description
 ~~~~~~~~~~~
 ~~~~~~~~~~~
 
 
-The prune command prunes a repository by deleting archives not matching
+The prune command prunes a repository by deleting all archives not matching
 any of the specified retention options. This command is normally used by
 any of the specified retention options. This command is normally used by
 automated backup scripts wanting to keep a certain number of historic backups.
 automated backup scripts wanting to keep a certain number of historic backups.
 
 
@@ -48,7 +48,7 @@ As an example, "-d 7" means to keep the latest backup on each day, up to 7
 most recent days with backups (days without backups do not count).
 most recent days with backups (days without backups do not count).
 The rules are applied from hourly to yearly, and backups selected by previous
 The rules are applied from hourly to yearly, and backups selected by previous
 rules do not count towards those of later rules. The time that each backup
 rules do not count towards those of later rules. The time that each backup
-completes is used for pruning purposes. Dates and times are interpreted in
+starts is used for pruning purposes. Dates and times are interpreted in
 the local timezone, and weeks go from Monday to Sunday. Specifying a
 the local timezone, and weeks go from Monday to Sunday. Specifying a
 negative number of archives to keep means that there is no limit.
 negative number of archives to keep means that there is no limit.
 
 

+ 100 - 59
src/borg/archive.py

@@ -98,8 +98,21 @@ class Statistics:
             print(msg, file=stream or sys.stderr, end="\r", flush=True)
             print(msg, file=stream or sys.stderr, end="\r", flush=True)
 
 
 
 
-class InputOSError(Exception):
-    """Wrapper for OSError raised while accessing input files."""
+def is_special(mode):
+    # file types that get special treatment in --read-special mode
+    return stat.S_ISBLK(mode) or stat.S_ISCHR(mode) or stat.S_ISFIFO(mode)
+
+
+class BackupOSError(Exception):
+    """
+    Wrapper for OSError raised while accessing backup files.
+
+    Borg does different kinds of IO, and IO failures have different consequences.
+    This wrapper represents failures of input file or extraction IO.
+    These are non-critical and are only reported (exit code = 1, warning).
+
+    Any unwrapped IO error is critical and aborts execution (for example repository IO failure).
+    """
     def __init__(self, os_error):
     def __init__(self, os_error):
         self.os_error = os_error
         self.os_error = os_error
         self.errno = os_error.errno
         self.errno = os_error.errno
@@ -111,18 +124,18 @@ class InputOSError(Exception):
 
 
 
 
 @contextmanager
 @contextmanager
-def input_io():
-    """Context manager changing OSError to InputOSError."""
+def backup_io():
+    """Context manager changing OSError to BackupOSError."""
     try:
     try:
         yield
         yield
     except OSError as os_error:
     except OSError as os_error:
-        raise InputOSError(os_error) from os_error
+        raise BackupOSError(os_error) from os_error
 
 
 
 
-def input_io_iter(iterator):
+def backup_io_iter(iterator):
     while True:
     while True:
         try:
         try:
-            with input_io():
+            with backup_io():
                 item = next(iterator)
                 item = next(iterator)
         except StopIteration:
         except StopIteration:
             return
             return
@@ -433,66 +446,80 @@ Number of files: {0.stats.nfiles}'''.format(
             pass
             pass
         mode = item.mode
         mode = item.mode
         if stat.S_ISREG(mode):
         if stat.S_ISREG(mode):
-            if not os.path.exists(os.path.dirname(path)):
-                os.makedirs(os.path.dirname(path))
-
+            with backup_io():
+                if not os.path.exists(os.path.dirname(path)):
+                    os.makedirs(os.path.dirname(path))
             # Hard link?
             # Hard link?
             if 'source' in item:
             if 'source' in item:
                 source = os.path.join(dest, item.source)
                 source = os.path.join(dest, item.source)
-                if os.path.exists(path):
-                    os.unlink(path)
-                if not hardlink_masters:
-                    os.link(source, path)
-                    return
+                with backup_io():
+                    if os.path.exists(path):
+                        os.unlink(path)
+                    if not hardlink_masters:
+                        os.link(source, path)
+                        return
                 item.chunks, link_target = hardlink_masters[item.source]
                 item.chunks, link_target = hardlink_masters[item.source]
                 if link_target:
                 if link_target:
                     # Hard link was extracted previously, just link
                     # Hard link was extracted previously, just link
-                    os.link(link_target, path)
+                    with backup_io():
+                        os.link(link_target, path)
                     return
                     return
                 # Extract chunks, since the item which had the chunks was not extracted
                 # Extract chunks, since the item which had the chunks was not extracted
-            with open(path, 'wb') as fd:
+            with backup_io():
+                fd = open(path, 'wb')
+            with fd:
                 ids = [c.id for c in item.chunks]
                 ids = [c.id for c in item.chunks]
                 for _, data in self.pipeline.fetch_many(ids, is_preloaded=True):
                 for _, data in self.pipeline.fetch_many(ids, is_preloaded=True):
-                    if sparse and self.zeros.startswith(data):
-                        # all-zero chunk: create a hole in a sparse file
-                        fd.seek(len(data), 1)
-                    else:
-                        fd.write(data)
-                pos = fd.tell()
-                fd.truncate(pos)
-                fd.flush()
-                self.restore_attrs(path, item, fd=fd.fileno())
+                    with backup_io():
+                        if sparse and self.zeros.startswith(data):
+                            # all-zero chunk: create a hole in a sparse file
+                            fd.seek(len(data), 1)
+                        else:
+                            fd.write(data)
+                with backup_io():
+                    pos = fd.tell()
+                    fd.truncate(pos)
+                    fd.flush()
+                    self.restore_attrs(path, item, fd=fd.fileno())
             if hardlink_masters:
             if hardlink_masters:
                 # Update master entry with extracted file path, so that following hardlinks don't extract twice.
                 # Update master entry with extracted file path, so that following hardlinks don't extract twice.
                 hardlink_masters[item.get('source') or original_path] = (None, path)
                 hardlink_masters[item.get('source') or original_path] = (None, path)
-        elif stat.S_ISDIR(mode):
-            if not os.path.exists(path):
-                os.makedirs(path)
-            if restore_attrs:
+            return
+        with backup_io():
+            # No repository access beyond this point.
+            if stat.S_ISDIR(mode):
+                if not os.path.exists(path):
+                    os.makedirs(path)
+                if restore_attrs:
+                    self.restore_attrs(path, item)
+            elif stat.S_ISLNK(mode):
+                if not os.path.exists(os.path.dirname(path)):
+                    os.makedirs(os.path.dirname(path))
+                source = item.source
+                if os.path.exists(path):
+                    os.unlink(path)
+                try:
+                    os.symlink(source, path)
+                except UnicodeEncodeError:
+                    raise self.IncompatibleFilesystemEncodingError(source, sys.getfilesystemencoding()) from None
+                self.restore_attrs(path, item, symlink=True)
+            elif stat.S_ISFIFO(mode):
+                if not os.path.exists(os.path.dirname(path)):
+                    os.makedirs(os.path.dirname(path))
+                os.mkfifo(path)
                 self.restore_attrs(path, item)
                 self.restore_attrs(path, item)
-        elif stat.S_ISLNK(mode):
-            if not os.path.exists(os.path.dirname(path)):
-                os.makedirs(os.path.dirname(path))
-            source = item.source
-            if os.path.exists(path):
-                os.unlink(path)
-            try:
-                os.symlink(source, path)
-            except UnicodeEncodeError:
-                raise self.IncompatibleFilesystemEncodingError(source, sys.getfilesystemencoding()) from None
-            self.restore_attrs(path, item, symlink=True)
-        elif stat.S_ISFIFO(mode):
-            if not os.path.exists(os.path.dirname(path)):
-                os.makedirs(os.path.dirname(path))
-            os.mkfifo(path)
-            self.restore_attrs(path, item)
-        elif stat.S_ISCHR(mode) or stat.S_ISBLK(mode):
-            os.mknod(path, item.mode, item.rdev)
-            self.restore_attrs(path, item)
-        else:
-            raise Exception('Unknown archive item type %r' % item.mode)
+            elif stat.S_ISCHR(mode) or stat.S_ISBLK(mode):
+                os.mknod(path, item.mode, item.rdev)
+                self.restore_attrs(path, item)
+            else:
+                raise Exception('Unknown archive item type %r' % item.mode)
 
 
     def restore_attrs(self, path, item, symlink=False, fd=None):
     def restore_attrs(self, path, item, symlink=False, fd=None):
+        """
+        Restore filesystem attributes on *path* (*fd*) from *item*.
+
+        Does not access the repository.
+        """
         uid = gid = None
         uid = gid = None
         if not self.numeric_owner:
         if not self.numeric_owner:
             uid = user2uid(item.user)
             uid = user2uid(item.user)
@@ -592,14 +619,14 @@ Number of files: {0.stats.nfiles}'''.format(
         )
         )
         if self.numeric_owner:
         if self.numeric_owner:
             attrs['user'] = attrs['group'] = None
             attrs['user'] = attrs['group'] = None
-        with input_io():
+        with backup_io():
             xattrs = xattr.get_all(path, follow_symlinks=False)
             xattrs = xattr.get_all(path, follow_symlinks=False)
         if xattrs:
         if xattrs:
             attrs['xattrs'] = StableDict(xattrs)
             attrs['xattrs'] = StableDict(xattrs)
         bsdflags = get_flags(path, st)
         bsdflags = get_flags(path, st)
         if bsdflags:
         if bsdflags:
             attrs['bsdflags'] = bsdflags
             attrs['bsdflags'] = bsdflags
-        with input_io():
+        with backup_io():
             acl_get(path, attrs, st, self.numeric_owner)
             acl_get(path, attrs, st, self.numeric_owner)
         return attrs
         return attrs
 
 
@@ -635,7 +662,7 @@ Number of files: {0.stats.nfiles}'''.format(
         uid, gid = 0, 0
         uid, gid = 0, 0
         fd = sys.stdin.buffer  # binary
         fd = sys.stdin.buffer  # binary
         chunks = []
         chunks = []
-        for data in input_io_iter(self.chunker.chunkify(fd)):
+        for data in backup_io_iter(self.chunker.chunkify(fd)):
             chunks.append(cache.add_chunk(self.key.id_hash(data), Chunk(data), self.stats))
             chunks.append(cache.add_chunk(self.key.id_hash(data), Chunk(data), self.stats))
         self.stats.nfiles += 1
         self.stats.nfiles += 1
         t = int(time.time()) * 1000000000
         t = int(time.time()) * 1000000000
@@ -664,9 +691,16 @@ Number of files: {0.stats.nfiles}'''.format(
                 return status
                 return status
             else:
             else:
                 self.hard_links[st.st_ino, st.st_dev] = safe_path
                 self.hard_links[st.st_ino, st.st_dev] = safe_path
-        path_hash = self.key.id_hash(safe_encode(os.path.join(self.cwd, path)))
+        is_special_file = is_special(st.st_mode)
+        if not is_special_file:
+            path_hash = self.key.id_hash(safe_encode(os.path.join(self.cwd, path)))
+            ids = cache.file_known_and_unchanged(path_hash, st, ignore_inode)
+        else:
+            # in --read-special mode, we may be called for special files.
+            # there should be no information in the cache about special files processed in
+            # read-special mode, but we better play safe as this was wrong in the past:
+            path_hash = ids = None
         first_run = not cache.files
         first_run = not cache.files
-        ids = cache.file_known_and_unchanged(path_hash, st, ignore_inode)
         if first_run:
         if first_run:
             logger.debug('Processing files ...')
             logger.debug('Processing files ...')
         chunks = None
         chunks = None
@@ -688,20 +722,27 @@ Number of files: {0.stats.nfiles}'''.format(
         if chunks is None:
         if chunks is None:
             compress = self.compression_decider1.decide(path)
             compress = self.compression_decider1.decide(path)
             logger.debug('%s -> compression %s', path, compress['name'])
             logger.debug('%s -> compression %s', path, compress['name'])
-            with input_io():
+            with backup_io():
                 fh = Archive._open_rb(path)
                 fh = Archive._open_rb(path)
             with os.fdopen(fh, 'rb') as fd:
             with os.fdopen(fh, 'rb') as fd:
                 chunks = []
                 chunks = []
-                for data in input_io_iter(self.chunker.chunkify(fd, fh)):
+                for data in backup_io_iter(self.chunker.chunkify(fd, fh)):
                     chunks.append(cache.add_chunk(self.key.id_hash(data),
                     chunks.append(cache.add_chunk(self.key.id_hash(data),
                                                   Chunk(data, compress=compress),
                                                   Chunk(data, compress=compress),
                                                   self.stats))
                                                   self.stats))
                     if self.show_progress:
                     if self.show_progress:
                         self.stats.show_progress(item=item, dt=0.2)
                         self.stats.show_progress(item=item, dt=0.2)
-            cache.memorize_file(path_hash, st, [c.id for c in chunks])
+            if not is_special_file:
+                # we must not memorize special files, because the contents of e.g. a
+                # block or char device will change without its mtime/size/inode changing.
+                cache.memorize_file(path_hash, st, [c.id for c in chunks])
             status = status or 'M'  # regular file, modified (if not 'A' already)
             status = status or 'M'  # regular file, modified (if not 'A' already)
         item.chunks = chunks
         item.chunks = chunks
         item.update(self.stat_attrs(st, path))
         item.update(self.stat_attrs(st, path))
+        if is_special_file:
+            # we processed a special file like a regular file. reflect that in mode,
+            # so it can be extracted / accessed in FUSE mount like a regular file:
+            item.mode = stat.S_IFREG | stat.S_IMODE(item.mode)
         self.stats.nfiles += 1
         self.stats.nfiles += 1
         self.add_item(item)
         self.add_item(item)
         return status
         return status

+ 58 - 40
src/borg/archiver.py

@@ -23,8 +23,8 @@ logger = create_logger()
 
 
 from . import __version__
 from . import __version__
 from . import helpers
 from . import helpers
-from .archive import Archive, ArchiveChecker, ArchiveRecreater, Statistics
-from .archive import InputOSError, CHUNKER_PARAMS
+from .archive import Archive, ArchiveChecker, ArchiveRecreater, Statistics, is_special
+from .archive import BackupOSError, CHUNKER_PARAMS
 from .cache import Cache
 from .cache import Cache
 from .constants import *  # NOQA
 from .constants import *  # NOQA
 from .helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR
 from .helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR
@@ -164,7 +164,7 @@ class Archiver:
     def do_serve(self, args):
     def do_serve(self, args):
         """Start in server mode. This command is usually not used manually.
         """Start in server mode. This command is usually not used manually.
         """
         """
-        return RepositoryServer(restrict_to_paths=args.restrict_to_paths).serve()
+        return RepositoryServer(restrict_to_paths=args.restrict_to_paths, append_only=args.append_only).serve()
 
 
     @with_repository(create=True, exclusive=True, manifest=False)
     @with_repository(create=True, exclusive=True, manifest=False)
     def do_init(self, args, repository):
     def do_init(self, args, repository):
@@ -255,7 +255,7 @@ class Archiver:
                     if not dry_run:
                     if not dry_run:
                         try:
                         try:
                             status = archive.process_stdin(path, cache)
                             status = archive.process_stdin(path, cache)
-                        except InputOSError as e:
+                        except BackupOSError as e:
                             status = 'E'
                             status = 'E'
                             self.print_warning('%s: %s', path, e)
                             self.print_warning('%s: %s', path, e)
                     else:
                     else:
@@ -313,15 +313,7 @@ class Archiver:
             return
             return
         if st is None:
         if st is None:
             try:
             try:
-                # usually, do not follow symlinks (if we have a symlink, we want to
-                # backup it as such).
-                # but if we are in --read-special mode, we later process <path> as
-                # a regular file (we open and read the symlink target file's content).
-                # thus, in read_special mode, we also want to stat the symlink target
-                # file, for consistency. if we did not, we also have issues extracting
-                # this file, as it would be in the archive as a symlink, not as the
-                # target's file type (which could be e.g. a block device).
-                st = os.stat(path, follow_symlinks=read_special)
+                st = os.lstat(path)
             except OSError as e:
             except OSError as e:
                 self.print_warning('%s: %s', path, e)
                 self.print_warning('%s: %s', path, e)
                 return
                 return
@@ -335,11 +327,11 @@ class Archiver:
         if get_flags(path, st) & stat.UF_NODUMP:
         if get_flags(path, st) & stat.UF_NODUMP:
             self.print_file_status('x', path)
             self.print_file_status('x', path)
             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):
             if not dry_run:
             if not dry_run:
                 try:
                 try:
                     status = archive.process_file(path, st, cache, self.ignore_inode)
                     status = archive.process_file(path, st, cache, self.ignore_inode)
-                except InputOSError as e:
+                except BackupOSError as e:
                     status = 'E'
                     status = 'E'
                     self.print_warning('%s: %s', path, e)
                     self.print_warning('%s: %s', path, e)
         elif stat.S_ISDIR(st.st_mode):
         elif stat.S_ISDIR(st.st_mode):
@@ -367,13 +359,26 @@ class Archiver:
                                   read_special=read_special, dry_run=dry_run)
                                   read_special=read_special, dry_run=dry_run)
         elif stat.S_ISLNK(st.st_mode):
         elif stat.S_ISLNK(st.st_mode):
             if not dry_run:
             if not dry_run:
-                status = archive.process_symlink(path, st)
+                if not read_special:
+                    status = archive.process_symlink(path, st)
+                else:
+                    st_target = os.stat(path)
+                    if is_special(st_target.st_mode):
+                        status = archive.process_file(path, st_target, cache)
+                    else:
+                        status = archive.process_symlink(path, st)
         elif stat.S_ISFIFO(st.st_mode):
         elif stat.S_ISFIFO(st.st_mode):
             if not dry_run:
             if not dry_run:
-                status = archive.process_fifo(path, st)
+                if not read_special:
+                    status = archive.process_fifo(path, st)
+                else:
+                    status = archive.process_file(path, st, cache)
         elif stat.S_ISCHR(st.st_mode) or stat.S_ISBLK(st.st_mode):
         elif stat.S_ISCHR(st.st_mode) or stat.S_ISBLK(st.st_mode):
             if not dry_run:
             if not dry_run:
-                status = archive.process_dev(path, st)
+                if not read_special:
+                    status = archive.process_dev(path, st)
+                else:
+                    status = archive.process_file(path, st, cache)
         elif stat.S_ISSOCK(st.st_mode):
         elif stat.S_ISSOCK(st.st_mode):
             # Ignore unix sockets
             # Ignore unix sockets
             return
             return
@@ -432,7 +437,11 @@ class Archiver:
                     continue
                     continue
             if not args.dry_run:
             if not args.dry_run:
                 while dirs and not item.path.startswith(dirs[-1].path):
                 while dirs and not item.path.startswith(dirs[-1].path):
-                    archive.extract_item(dirs.pop(-1), stdout=stdout)
+                    dir_item = dirs.pop(-1)
+                    try:
+                        archive.extract_item(dir_item, stdout=stdout)
+                    except BackupOSError as e:
+                        self.print_warning('%s: %s', remove_surrogates(dir_item[b'path']), e)
             if output_list:
             if output_list:
                 logging.getLogger('borg.output.list').info(remove_surrogates(orig_path))
                 logging.getLogger('borg.output.list').info(remove_surrogates(orig_path))
             try:
             try:
@@ -445,12 +454,16 @@ class Archiver:
                     else:
                     else:
                         archive.extract_item(item, stdout=stdout, sparse=sparse, hardlink_masters=hardlink_masters,
                         archive.extract_item(item, stdout=stdout, sparse=sparse, hardlink_masters=hardlink_masters,
                                              original_path=orig_path)
                                              original_path=orig_path)
-            except OSError as e:
+            except BackupOSError as e:
                 self.print_warning('%s: %s', remove_surrogates(orig_path), e)
                 self.print_warning('%s: %s', remove_surrogates(orig_path), e)
 
 
         if not args.dry_run:
         if not args.dry_run:
             while dirs:
             while dirs:
-                archive.extract_item(dirs.pop(-1))
+                dir_item = dirs.pop(-1)
+                try:
+                    archive.extract_item(dir_item)
+                except BackupOSError as e:
+                    self.print_warning('%s: %s', remove_surrogates(dir_item[b'path']), e)
         for pattern in include_patterns:
         for pattern in include_patterns:
             if pattern.match_count == 0:
             if pattern.match_count == 0:
                 self.print_warning("Include pattern '%s' never matched.", pattern)
                 self.print_warning("Include pattern '%s' never matched.", pattern)
@@ -1033,26 +1046,27 @@ class Archiver:
     helptext = {}
     helptext = {}
     helptext['patterns'] = textwrap.dedent('''
     helptext['patterns'] = textwrap.dedent('''
         Exclusion patterns support four separate styles, fnmatch, shell, regular
         Exclusion patterns support four separate styles, fnmatch, shell, regular
-        expressions and path prefixes. If followed by a colon (':') the first two
-        characters of a pattern are used as a style selector. Explicit style
-        selection is necessary when a non-default style is desired or when the
-        desired pattern starts with two alphanumeric characters followed by a colon
-        (i.e. `aa:something/*`).
+        expressions and path prefixes. By default, fnmatch is used. If followed
+        by a colon (':') the first two characters of a pattern are used as a
+        style selector. Explicit style selection is necessary when a
+        non-default style is desired or when the desired pattern starts with
+        two alphanumeric characters followed by a colon (i.e. `aa:something/*`).
 
 
         `Fnmatch <https://docs.python.org/3/library/fnmatch.html>`_, selector `fm:`
         `Fnmatch <https://docs.python.org/3/library/fnmatch.html>`_, selector `fm:`
 
 
-            These patterns use a variant of shell pattern syntax, with '*' matching
-            any number of characters, '?' matching any single character, '[...]'
-            matching any single character specified, including ranges, and '[!...]'
-            matching any character not specified. For the purpose of these patterns,
-            the path separator ('\\' for Windows and '/' on other systems) is not
-            treated specially. Wrap meta-characters in brackets for a literal match
-            (i.e. `[?]` to match the literal character `?`). For a path to match
-            a pattern, it must completely match from start to end, or must match from
-            the start to just before a path separator. Except for the root path,
-            paths will never end in the path separator when matching is attempted.
-            Thus, if a given pattern ends in a path separator, a '*' is appended
-            before matching is attempted.
+            This is the default style.  These patterns use a variant of shell
+            pattern syntax, with '*' matching any number of characters, '?'
+            matching any single character, '[...]' matching any single
+            character specified, including ranges, and '[!...]' matching any
+            character not specified. For the purpose of these patterns, the
+            path separator ('\\' for Windows and '/' on other systems) is not
+            treated specially. Wrap meta-characters in brackets for a literal
+            match (i.e. `[?]` to match the literal character `?`). For a path
+            to match a pattern, it must completely match from start to end, or
+            must match from the start to just before a path separator. Except
+            for the root path, paths will never end in the path separator when
+            matching is attempted.  Thus, if a given pattern ends in a path
+            separator, a '*' is appended before matching is attempted.
 
 
         Shell-style patterns, selector `sh:`
         Shell-style patterns, selector `sh:`
 
 
@@ -1229,6 +1243,8 @@ class Archiver:
         subparser.set_defaults(func=self.do_serve)
         subparser.set_defaults(func=self.do_serve)
         subparser.add_argument('--restrict-to-path', dest='restrict_to_paths', action='append',
         subparser.add_argument('--restrict-to-path', dest='restrict_to_paths', action='append',
                                metavar='PATH', help='restrict repository access to PATH')
                                metavar='PATH', help='restrict repository access to PATH')
+        subparser.add_argument('--append-only', dest='append_only', action='store_true',
+                               help='only allow appending to repository segment files')
         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.
@@ -1485,7 +1501,8 @@ class Archiver:
                               help='ignore inode data in the file metadata cache used to detect unchanged files.')
                               help='ignore inode data in the file metadata cache used to detect unchanged files.')
         fs_group.add_argument('--read-special', dest='read_special',
         fs_group.add_argument('--read-special', dest='read_special',
                               action='store_true', default=False,
                               action='store_true', default=False,
-                              help='open and read special files as if they were regular files')
+                              help='open and read block and char device files as well as FIFOs as if they were '
+                                   'regular files. Also follows symlinks pointing to these kinds of files.')
 
 
         archive_group = subparser.add_argument_group('Archive options')
         archive_group = subparser.add_argument_group('Archive options')
         archive_group.add_argument('--comment', dest='comment', metavar='COMMENT', default='',
         archive_group.add_argument('--comment', dest='comment', metavar='COMMENT', default='',
@@ -2123,8 +2140,9 @@ class Archiver:
             if result.func != forced_result.func:
             if result.func != forced_result.func:
                 # someone is trying to execute a different borg subcommand, don't do that!
                 # someone is trying to execute a different borg subcommand, don't do that!
                 return forced_result
                 return forced_result
-            # the only thing we take from the forced "borg serve" ssh command is --restrict-to-path
+            # we only take specific options from the forced "borg serve" command:
             result.restrict_to_paths = forced_result.restrict_to_paths
             result.restrict_to_paths = forced_result.restrict_to_paths
+            result.append_only = forced_result.append_only
         return result
         return result
 
 
     def parse_args(self, args=None):
     def parse_args(self, args=None):

+ 11 - 5
src/borg/remote.py

@@ -58,9 +58,10 @@ class RepositoryServer:  # pragma: no cover
         'break_lock',
         'break_lock',
     )
     )
 
 
-    def __init__(self, restrict_to_paths):
+    def __init__(self, restrict_to_paths, append_only):
         self.repository = None
         self.repository = None
         self.restrict_to_paths = restrict_to_paths
         self.restrict_to_paths = restrict_to_paths
+        self.append_only = append_only
 
 
     def serve(self):
     def serve(self):
         stdin_fd = sys.stdin.fileno()
         stdin_fd = sys.stdin.fileno()
@@ -127,7 +128,7 @@ class RepositoryServer:  # pragma: no cover
                     break
                     break
             else:
             else:
                 raise PathNotAllowed(path)
                 raise PathNotAllowed(path)
-        self.repository = Repository(path, create, lock_wait=lock_wait, lock=lock)
+        self.repository = Repository(path, create, lock_wait=lock_wait, lock=lock, append_only=self.append_only)
         self.repository.__enter__()  # clean exit handled by serve() method
         self.repository.__enter__()  # clean exit handled by serve() method
         return self.repository.id
         return self.repository.id
 
 
@@ -192,9 +193,14 @@ class RemoteRepository:
         return self
         return self
 
 
     def __exit__(self, exc_type, exc_val, exc_tb):
     def __exit__(self, exc_type, exc_val, exc_tb):
-        if exc_type is not None:
-            self.rollback()
-        self.close()
+        try:
+            if exc_type is not None:
+                self.rollback()
+        finally:
+            # in any case, we want to cleanly close the repo, even if the
+            # rollback can not succeed (e.g. because the connection was
+            # already closed) and raised another exception:
+            self.close()
 
 
     @property
     @property
     def id_str(self):
     def id_str(self):

+ 5 - 2
src/borg/repository.py

@@ -96,7 +96,7 @@ class Repository:
     class ObjectNotFound(ErrorWithTraceback):
     class ObjectNotFound(ErrorWithTraceback):
         """Object with key {} not found in repository {}."""
         """Object with key {} not found in repository {}."""
 
 
-    def __init__(self, path, create=False, exclusive=False, lock_wait=None, lock=True):
+    def __init__(self, path, create=False, exclusive=False, lock_wait=None, lock=True, append_only=False):
         self.path = os.path.abspath(path)
         self.path = os.path.abspath(path)
         self._location = Location('file://%s' % self.path)
         self._location = Location('file://%s' % self.path)
         self.io = None
         self.io = None
@@ -107,6 +107,7 @@ class Repository:
         self.do_lock = lock
         self.do_lock = lock
         self.do_create = create
         self.do_create = create
         self.exclusive = exclusive
         self.exclusive = exclusive
+        self.append_only = append_only
 
 
     def __del__(self):
     def __del__(self):
         if self.lock:
         if self.lock:
@@ -219,7 +220,9 @@ class Repository:
             raise self.InvalidRepository(path)
             raise self.InvalidRepository(path)
         self.max_segment_size = self.config.getint('repository', 'max_segment_size')
         self.max_segment_size = self.config.getint('repository', 'max_segment_size')
         self.segments_per_dir = self.config.getint('repository', 'segments_per_dir')
         self.segments_per_dir = self.config.getint('repository', 'segments_per_dir')
-        self.append_only = self.config.getboolean('repository', 'append_only', fallback=False)
+        # append_only can be set in the constructor
+        # it shouldn't be overridden (True -> False) here
+        self.append_only = self.append_only or self.config.getboolean('repository', 'append_only', fallback=False)
         self.id = unhexlify(self.config.get('repository', 'id').strip())
         self.id = unhexlify(self.config.get('repository', 'id').strip())
         self.io = LoggedIO(self.path, self.max_segment_size, self.segments_per_dir)
         self.io = LoggedIO(self.path, self.max_segment_size, self.segments_per_dir)
 
 

+ 8 - 8
src/borg/testsuite/archive.py

@@ -7,7 +7,7 @@ import pytest
 import msgpack
 import msgpack
 
 
 from ..archive import Archive, CacheChunkBuffer, RobustUnpacker, valid_msgpacked_dict, ITEM_KEYS, Statistics
 from ..archive import Archive, CacheChunkBuffer, RobustUnpacker, valid_msgpacked_dict, ITEM_KEYS, Statistics
-from ..archive import InputOSError, input_io, input_io_iter
+from ..archive import BackupOSError, backup_io, backup_io_iter
 from ..item import Item
 from ..item import Item
 from ..key import PlaintextKey
 from ..key import PlaintextKey
 from ..helpers import Manifest
 from ..helpers import Manifest
@@ -219,13 +219,13 @@ def test_key_length_msgpacked_items():
     assert valid_msgpacked_dict(msgpack.packb(data), item_keys_serialized)
     assert valid_msgpacked_dict(msgpack.packb(data), item_keys_serialized)
 
 
 
 
-def test_input_io():
-    with pytest.raises(InputOSError):
-        with input_io():
+def test_backup_io():
+    with pytest.raises(BackupOSError):
+        with backup_io():
             raise OSError(123)
             raise OSError(123)
 
 
 
 
-def test_input_io_iter():
+def test_backup_io_iter():
     class Iterator:
     class Iterator:
         def __init__(self, exc):
         def __init__(self, exc):
             self.exc = exc
             self.exc = exc
@@ -234,10 +234,10 @@ def test_input_io_iter():
             raise self.exc()
             raise self.exc()
 
 
     oserror_iterator = Iterator(OSError)
     oserror_iterator = Iterator(OSError)
-    with pytest.raises(InputOSError):
-        for _ in input_io_iter(oserror_iterator):
+    with pytest.raises(BackupOSError):
+        for _ in backup_io_iter(oserror_iterator):
             pass
             pass
 
 
     normal_iterator = Iterator(StopIteration)
     normal_iterator = Iterator(StopIteration)
-    for _ in input_io_iter(normal_iterator):
+    for _ in backup_io_iter(normal_iterator):
         assert False, 'StopIteration handled incorrectly'
         assert False, 'StopIteration handled incorrectly'

+ 4 - 1
src/borg/testsuite/repository.py

@@ -244,11 +244,14 @@ class RepositoryCommitTestCase(RepositoryTestCaseBase):
 
 
 
 
 class RepositoryAppendOnlyTestCase(RepositoryTestCaseBase):
 class RepositoryAppendOnlyTestCase(RepositoryTestCaseBase):
+    def open(self, create=False):
+        return Repository(os.path.join(self.tmppath, 'repository'), create=create, append_only=True)
+
     def test_destroy_append_only(self):
     def test_destroy_append_only(self):
         # Can't destroy append only repo (via the API)
         # Can't destroy append only repo (via the API)
-        self.repository.append_only = True
         with self.assert_raises(ValueError):
         with self.assert_raises(ValueError):
             self.repository.destroy()
             self.repository.destroy()
+        assert self.repository.append_only
 
 
     def test_append_only(self):
     def test_append_only(self):
         def segments_in_repository():
         def segments_in_repository():