]> arthur.barton.de Git - bup.git/commitdiff
Restore any metadata during "bup restore"; add "bup meta --edit".
authorRob Browning <rlb@defaultvalue.org>
Sun, 27 Nov 2011 20:39:47 +0000 (14:39 -0600)
committerRob Browning <rlb@defaultvalue.org>
Sat, 22 Dec 2012 01:55:12 +0000 (19:55 -0600)
Use "bup meta --edit" to test "bup restore" and the new tar/rsync-like
restoration behaviors.

Signed-off-by: Rob Browning <rlb@defaultvalue.org>
Reviewed-by: Zoran Zaric <zz@zoranzaric.de>
Tested-by: Alexander Barton <alex@barton.de>
Documentation/bup-meta.md
Documentation/bup-restore.md
Documentation/bup-save.md
cmd/meta-cmd.py
cmd/restore-cmd.py
lib/bup/metadata.py
lib/bup/t/tmetadata.py
lib/bup/vfs.py
t/some-owner [new file with mode: 0755]
t/test-meta.sh
t/unknown-owner [new file with mode: 0755]

index a53cc4cdaa365d8b5163681606188c083e59dd7e..00096ddea16485a5ffd9660e7159cdd3ff26abe8 100644 (file)
@@ -23,11 +23,18 @@ bup meta \--start-extract
 bup meta \--finish-extract
   ~ [-v] [-q] [\--numeric-ids] [-f *file*]
 
+bup meta \--edit
+  ~ [\--set-uid *uid* | \--set-gid *gid* | \--set-user *user* | \--set-group *group* | ...] \<*paths*...\>
+
 # DESCRIPTION
 
-`bup meta` either creates or extracts a metadata archive.  A metadata
-archive contains the metadata information (timestamps, ownership,
-access permissions, etc.) for a set of filesystem paths.
+`bup meta` creates, extracts, or otherwise manipulates metadata
+archives.  A metadata archive contains the metadata information
+(timestamps, ownership, access permissions, etc.) for a set of
+filesystem paths.
+
+See `bup-restore`(1) for a description of the way ownership metadata
+is restored.
 
 # OPTIONS
 
@@ -61,6 +68,10 @@ access permissions, etc.) for a set of filesystem paths.
     `--start-extract`.  The archive will be read from standard input
     unless `--file` is specified.
 
+\--edit
+:   Edit metadata archives.  The result will be written to standard
+    output unless `--file` is specified.
+
 -f, \--file=*filename*
 :   Read the metadata archive from *filename* or write it to
     *filename* as appropriate.  If *filename* is "-", then read from
@@ -70,7 +81,7 @@ access permissions, etc.) for a set of filesystem paths.
 :   Recursively descend into subdirectories during `--create`.
 
 \--numeric-ids
-:   Apply numeric user and group IDs (rather than text IDs) during
+:   Apply numeric IDs (user, group, etc.) rather than names during
     `--extract` or `--finish-extract`.
 
 \--symlinks
@@ -83,6 +94,24 @@ access permissions, etc.) for a set of filesystem paths.
 :   Record pathnames when creating an archive.  This option is enabled
     by default.  Specify `--no-paths` to disable it.
 
+\--set-uid=*uid*
+:   Set the metadata uid to the integer *uid* during `--edit`.
+
+\--set-gid=*gid*
+:   Set the metadata gid to the integer *gid* during `--edit`.
+
+\--set-user=*user*
+:   Set the metadata user to *user* during `--edit`.
+
+\--unset-user
+:   Remove the metadata user during `--edit`.
+
+\--set-group=*group*
+:   Set the metadata user to *group* during `--edit`.
+
+\--unset-group
+:   Remove the metadata group during `--edit`.
+
 -v, \--verbose
 :   Be more verbose (can be used more than once).
 
@@ -107,6 +136,10 @@ access permissions, etc.) for a set of filesystem paths.
     ...fill in all regular file contents using some other tool...
     $ bup meta --finish-extract -f ../etc.meta
 
+    # Change user/uid to root.
+    $ bup meta --edit --set-uid 0 --set-user root \
+        src.meta > dest.meta
+
 # BUGS
 
 Hard links are not handled yet.
index f5a2ad4050c9b9001c78962824057f540772f6f9..5085a67713ce894d2ff30ac21201a989bbe57a24 100644 (file)
@@ -45,6 +45,21 @@ the children will be restored to a subdirectory of the
 current directory.  See the EXAMPLES section to see how
 this works.
 
+Whenever path metadata is available, `bup restore` will attempt to
+restore it.  When restoring ownership, bup implements tar/rsync-like
+semantics.  It will not try to restore the user unless running as
+root, and it will fall back to the numeric uid or gid whenever the
+metadata contains a user or group name that doesn't exist on the
+current system.  The use of user and group names can be disabled via
+`--numeric-ids` (which can be important when restoring a chroot, for
+example), and as a special case, a uid or gid of 0 will never be
+remapped by name.
+
+Note that during the restoration process, access to data within the
+restore tree may be more permissive than it was in the original
+source.  Unless security is irrelevant, you must restore to a private
+subdirectory, and then move the resulting tree to its final position.
+See the EXAMPLES section for a demonstration.
 
 # OPTIONS
 
@@ -52,6 +67,9 @@ this works.
 :   create and change to directory *outdir* before
     extracting the files.
 
+\--numeric-ids
+:   restore numeric IDs (user, group, etc.) rather than names.
+
 -v, \--verbose
 :   increase log output.  Given once, prints every
     directory as it is restored; given twice, prints every
@@ -97,6 +115,17 @@ Restore the whole directory (trailing slash):
     test2
     test2/passwd
     test2/profile
+
+Restore a tree without risk of unauthorized access:
+
+    # mkdir --mode 0700 restore-tmp
+
+    # bup restore -C restore-tmp /somebackup/latest/foo
+    Restoring: 42, done.
+
+    # mv restore-tmp/foo somewhere
+
+    # rmdir restore-tmp
     
 
 # SEE ALSO
index a62eb089e72a4e1c42065bd1c1d3c68a599c17e2..0384ff29d961a17113da1937cf9e271cd6dc8bc6 100644 (file)
@@ -23,7 +23,8 @@ for `bup-index`(1).
 
 By default, metadata will be saved for every path.  However, if
 `--strip`, `--strip-path`, or `--graft` is specified, metadata will
-not be saved for the root directory (*/*).
+not be saved for the root directory (*/*).  See `bup-restore`(1) for
+more information about the handling of metadata.
 
 # OPTIONS
 
index b557ba3a661364747188d019861036fdeb336a67..158df92a32cdbd0835e2423ef350df7f47d5d4cd 100755 (executable)
@@ -6,7 +6,6 @@
 # Public License as described in the bup LICENSE file.
 
 # TODO: Add tar-like -C option.
-# TODO: Add tar-like -v support to --list.
 
 import sys
 from bup import metadata
@@ -31,18 +30,26 @@ bup meta --create [OPTION ...] <PATH ...>
 bup meta --extract [OPTION ...]
 bup meta --start-extract [OPTION ...]
 bup meta --finish-extract [OPTION ...]
+bup meta --edit [OPTION ...] <PATH ...>
 --
 c,create       write metadata for PATHs to stdout (or --file)
 t,list         display metadata
 x,extract      perform --start-extract followed by --finish-extract
 start-extract  build tree matching metadata provided on standard input (or --file)
 finish-extract finish applying standard input (or --file) metadata to filesystem
+edit           alter metadata; write to stdout (or --file)
 f,file=        specify source or destination file
 R,recurse      recurse into subdirectories
 xdev,one-file-system  don't cross filesystem boundaries
-numeric-ids    apply numeric IDs (user, group, etc.), not names, during restore
+numeric-ids    apply numeric IDs (user, group, etc.) rather than names
 symlinks       handle symbolic links (default is true)
 paths          include paths in metadata (default is true)
+set-uid=       set metadata uid (via --edit)
+set-gid=       set metadata gid (via --edit)
+set-user=      set metadata user (via --edit)
+unset-user     remove metadata user (via --edit)
+set-group=     set metadata group (via --edit)
+unset-group    remove metadata group (via --edit)
 v,verbose      increase log output (can be used more than once)
 q,quiet        don't show progress meter
 """
@@ -57,9 +64,10 @@ opt.quiet = opt.quiet or 0
 metadata.verbose = opt.verbose - opt.quiet
 
 action_count = sum([bool(x) for x in [opt.create, opt.list, opt.extract,
-                                      opt.start_extract, opt.finish_extract]])
+                                      opt.start_extract, opt.finish_extract,
+                                      opt.edit]])
 if action_count > 1:
-    o.fatal("bup: only one action permitted: --create --list --extract")
+    o.fatal("bup: only one action permitted: --create --list --extract --edit")
 if action_count == 0:
     o.fatal("bup: no action specified")
 
@@ -95,6 +103,53 @@ elif opt.extract:
     metadata.extract(src,
                      restore_numeric_ids=opt.numeric_ids,
                      create_symlinks=opt.symlinks)
+elif opt.edit:
+    if len(remainder) < 1:
+        o.fatal("no paths specified for edit")
+    output_file = open_output(opt.file)
+
+    unset_user = False # True if --unset-user was the last relevant option.
+    unset_group = False # True if --unset-group was the last relevant option.
+    for flag in flags:
+        if flag[0] == '--set-user':
+            unset_user = False
+        elif flag[0] == '--unset-user':
+            unset_user = True
+        elif flag[0] == '--set-group':
+            unset_group = False
+        elif flag[0] == '--unset-group':
+            unset_group = True
+
+    for path in remainder:
+        f = open(path, 'r')
+        try:
+            for m in metadata._ArchiveIterator(f):
+                if opt.set_uid is not None:
+                    try:
+                        m.uid = int(opt.set_uid)
+                    except ValueError:
+                        o.fatal("uid must be an integer")
+
+                if opt.set_gid is not None:
+                    try:
+                        m.gid = int(opt.set_gid)
+                    except ValueError:
+                        o.fatal("gid must be an integer")
+
+                if unset_user:
+                    m.user = ''
+                elif opt.set_user is not None:
+                    m.user = opt.set_user
+
+                if unset_group:
+                    m.group = ''
+                elif opt.set_group is not None:
+                    m.group = opt.set_group
+
+                m.write(output_file)
+        finally:
+            f.close()
+
 
 if saved_errors:
     log('WARNING: %d errors encountered.\n' % len(saved_errors))
index 445d9d05ccfc1da9a255a163f1b45e367d203ce8..ff2d8b2fcf08a13233bfc5e78d0dcb2c419227b9 100755 (executable)
@@ -1,14 +1,15 @@
 #!/usr/bin/env python
 import sys, stat
-from bup import options, git, vfs
+from bup import options, git, metadata, vfs
 from bup.helpers import *
 
 optspec = """
 bup restore [-C outdir] </branch/revision/path/to/dir ...>
 --
-C,outdir=  change to given outdir before extracting files
-v,verbose  increase log output (can be used more than once)
-q,quiet    don't show progress meter
+C,outdir=   change to given outdir before extracting files
+numeric-ids restore numeric IDs (user, group, etc.) rather than names
+v,verbose   increase log output (can be used more than once)
+q,quiet     don't show progress meter
 """
 
 total_restored = 0
@@ -30,30 +31,76 @@ def plog(s):
     qprogress(s)
 
 
-def do_node(top, n):
-    global total_restored
-    fullname = n.fullname(stop_at=top)
-    unlink(fullname)
+def print_info(n, fullname):
     if stat.S_ISDIR(n.mode):
         verbose1('%s/' % fullname)
-        mkdirp(fullname)
     elif stat.S_ISLNK(n.mode):
         verbose2('%s@ -> %s' % (fullname, n.readlink()))
-        os.symlink(n.readlink(), fullname)
     else:
         verbose2(fullname)
-        outf = open(fullname, 'wb')
-        try:
-            for b in chunkyreader(n.open()):
-                outf.write(b)
-        finally:
-            outf.close()
-    total_restored += 1
-    plog('Restoring: %d\r' % total_restored)
-    for sub in n:
-        do_node(top, sub)
-
-        
+
+
+def create_path(n, fullname, meta):
+    if meta:
+        meta.create_path(fullname)
+    else:
+        # These fallbacks are important -- meta could be null if, for
+        # example, save created a "fake" item, i.e. a new strip/graft
+        # path element, etc.  You can find cases like that by
+        # searching for "Metadata()".
+        unlink(fullname)
+        if stat.S_ISDIR(n.mode):
+            mkdirp(fullname)
+        elif stat.S_ISLNK(n.mode):
+            os.symlink(n.readlink(), fullname)
+
+
+def do_node(top, n, meta=None):
+    # meta will be None for dirs, and when there is no .bupm (i.e. no metadata)
+    global total_restored, opt
+    meta_stream = None
+    try:
+        fullname = n.fullname(stop_at=top)
+        # If this is a directory, its metadata is the first entry in
+        # any .bupm file inside the directory.  Get it.
+        if(stat.S_ISDIR(n.mode)):
+            mfile = n.metadata_file() # VFS file -- cannot close().
+            if mfile:
+                meta_stream = mfile.open()
+                meta = metadata.Metadata.read(meta_stream)
+        print_info(n, fullname)
+        create_path(n, fullname, meta)
+
+        # Write content if appropriate (only regular files have content).
+        plain_file = False
+        if meta:
+            plain_file = stat.S_ISREG(meta.mode)
+        else:
+            plain_file = stat.S_ISREG(n.mode)
+
+        if plain_file:
+            outf = open(fullname, 'wb')
+            try:
+                for b in chunkyreader(n.open()):
+                    outf.write(b)
+            finally:
+                outf.close()
+
+        total_restored += 1
+        plog('Restoring: %d\r' % total_restored)
+        for sub in n:
+            m = None
+            # Don't get metadata if this is a dir -- handled in sub do_node().
+            if meta_stream and not stat.S_ISDIR(sub.mode):
+                m = metadata.Metadata.read(meta_stream)
+            do_node(top, sub, m)
+        if meta:
+            meta.apply_to_path(fullname,
+                               restore_numeric_ids=opt.numeric_ids)
+    finally:
+        if meta_stream:
+            meta_stream.close()
+
 handle_ctrl_c()
 
 o = options.Options(optspec)
index 0e54d5cd12d09c432fbfc0ef301be732d2a51234..87fd10b84a84383b63c075f0467767bba33ac0c5 100644 (file)
@@ -196,14 +196,16 @@ class Metadata:
         self.mtime = st.st_mtime
         self.ctime = st.st_ctime
         self.user = self.group = ''
+        # FIXME: should we be caching id -> user/group name mappings?
+        # IIRC, tar uses some trick -- possibly caching the last pair.
         try:
             self.user = pwd.getpwuid(st.st_uid)[0]
         except KeyError, e:
-            add_error("no user name for id %s '%s'" % (st.st_gid, path))
+            pass
         try:
             self.group = grp.getgrgid(st.st_gid)[0]
         except KeyError, e:
-            add_error("no group name for id %s '%s'" % (st.st_gid, path))
+            pass
         self.mode = st.st_mode
 
     def _encode_common(self):
@@ -336,43 +338,44 @@ class Metadata:
                 else:
                     raise
 
-        # Don't try to restore user unless we're root, and even
-        # if asked, don't try to restore the user or group if
-        # it doesn't exist in the system db.
-        uid = self.uid
-        gid = self.gid
-        if not restore_numeric_ids:
-            if not self.user:
-                uid = -1
-                add_error('ignoring missing user for "%s"\n' % path)
-            else:
-                if not is_superuser():
-                    uid = -1 # Not root; assume we can't change user.
-                else:
+        # Implement tar/rsync-like semantics; see bup-restore(1).
+        # FIXME: should we consider caching user/group name <-> id
+        # mappings, getgroups(), etc.?
+        uid = gid = -1 # By default, do nothing.
+        if is_superuser():
+            uid = self.uid
+            gid = self.gid
+            if not restore_numeric_ids:
+                if self.uid != 0 and self.user:
                     try:
                         uid = pwd.getpwnam(self.user)[2]
                     except KeyError:
-                        uid = -1
-                        fmt = 'ignoring unknown user %s for "%s"\n'
-                        add_error(fmt % (self.user, path))
-            if not self.group:
-                gid = -1
-                add_error('ignoring missing group for "%s"\n' % path)
-            else:
+                        pass # Fall back to self.uid.
+                if self.gid != 0 and self.group:
+                    try:
+                        gid = grp.getgrnam(self.group)[2]
+                    except KeyError:
+                        pass # Fall back to self.gid.
+        else: # not superuser - only consider changing the group/gid
+            user_gids = os.getgroups()
+            if self.gid in user_gids:
+                gid = self.gid
+            if not restore_numeric_ids and \
+                    self.gid != 0 and \
+                    self.group in [grp.getgrgid(x)[0] for x in user_gids]:
                 try:
                     gid = grp.getgrnam(self.group)[2]
                 except KeyError:
-                    gid = -1
-                    add_error('ignoring unknown group %s for "%s"\n'
-                              % (self.group, path))
+                    pass # Fall back to gid.
 
-        try:
-            os.lchown(path, uid, gid)
-        except OSError, e:
-            if e.errno == errno.EPERM:
-                add_error('lchown: %s' %  e)
-            else:
-                raise
+        if uid != -1 or gid != -1:
+            try:
+                os.lchown(path, uid, gid)
+            except OSError, e:
+                if e.errno == errno.EPERM:
+                    add_error('lchown: %s' %  e)
+                else:
+                    raise
 
         if _have_lchmod:
             os.lchmod(path, stat.S_IMODE(self.mode))
index 89a440f9341ea6149a14d541c126ac6a13f2ee3f..ca2e55c53134b3099c4fce6897f7c2f08281ef2d 100644 (file)
@@ -156,57 +156,6 @@ def test_apply_to_path_restricted_access():
         subprocess.call(['rm', '-rf', tmpdir])
 
 
-@wvtest
-def test_restore_restricted_user_group():
-    if is_superuser() or detect_fakeroot():
-        return
-    tmpdir = tempfile.mkdtemp(prefix='bup-tmetadata-')
-    try:
-        path = tmpdir + '/foo'
-        os.mkdir(path)
-        m = metadata.from_path(path, archive_path=path, save_symlinks=True)
-        WVPASSEQ(m.path, path)
-        WVPASSEQ(m.apply_to_path(path), None)
-        orig_uid = m.uid
-        m.uid = 0;
-        m.apply_to_path(path, restore_numeric_ids=True)
-        WVPASS(len(helpers.saved_errors) == 1)
-        errmsg = _first_err()
-        WVPASS(errmsg.startswith('lchown: '))
-        clear_errors()
-        m.uid = orig_uid
-        m.gid = 0;
-        m.apply_to_path(path, restore_numeric_ids=True)
-        WVPASS(len(helpers.saved_errors) == 1)
-        errmsg = _first_err()
-        WVPASS(errmsg.startswith('lchown: ') or os.stat(path).st_gid == m.gid)
-        clear_errors()
-    finally:
-        subprocess.call(['rm', '-rf', tmpdir])
-
-
-@wvtest
-def test_restore_nonexistent_user_group():
-    tmpdir = tempfile.mkdtemp(prefix='bup-tmetadata-')
-    try:
-        path = tmpdir + '/foo'
-        os.mkdir(path)
-        m = metadata.from_path(path, archive_path=path, save_symlinks=True)
-        WVPASSEQ(m.path, path)
-        junk,m.user = max([(len(x.pw_name), x.pw_name + 'x')
-                           for x in pwd.getpwall()])
-        junk,m.group = max([(len(x.gr_name), x.gr_name + 'x')
-                            for x in grp.getgrall()])
-        WVPASSEQ(m.apply_to_path(path, restore_numeric_ids=True), None)
-        WVPASSEQ(os.stat(path).st_uid, m.uid)
-        WVPASSEQ(os.stat(path).st_gid, m.gid)
-        WVPASSEQ(m.apply_to_path(path, restore_numeric_ids=False), None)
-        WVPASSEQ(os.stat(path).st_uid, m.uid)
-        WVPASSEQ(os.stat(path).st_gid, m.gid)
-    finally:
-        subprocess.call(['rm', '-rf', tmpdir])
-
-
 @wvtest
 def test_restore_over_existing_target():
     tmpdir = tempfile.mkdtemp(prefix='bup-tmetadata-')
index ccedffc2bd8b8407a73f56f9ab745e40a792d847..f58dd5e31d3069dff375743376ffebf3c0671f55 100644 (file)
@@ -386,7 +386,7 @@ class Dir(Node):
 
     def __init__(self, *args):
         Node.__init__(self, *args)
-        self._metadata_sha = None
+        self._metadata = None
 
     def _mksubs(self):
         self._subs = {}
@@ -399,7 +399,8 @@ class Dir(Node):
         assert(type == 'tree')
         for (mode,mangled_name,sha) in git.tree_decode(''.join(it)):
             if mangled_name == '.bupm':
-                self._metadata_sha = sha
+                self._metadata = \
+                    File(self, mangled_name, mode, sha, git.BUP_NORMAL)
                 continue
             name = mangled_name
             (name,bupmode) = git.demangle_name(mangled_name)
@@ -412,6 +413,11 @@ class Dir(Node):
             else:
                 self._subs[name] = File(self, name, mode, sha, bupmode)
 
+    def metadata_file(self):
+        if self._subs == None:
+            self._mksubs()
+        return self._metadata
+
 
 class CommitDir(Node):
     """A directory that contains all commits that are reachable by a ref.
diff --git a/t/some-owner b/t/some-owner
new file mode 100755 (executable)
index 0000000..e200fae
--- /dev/null
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+
+import grp
+import pwd
+import sys
+
+def usage():
+    print >> sys.stderr, "Usage: some-owners (--user | --group)"
+
+if len(sys.argv) != 2:
+    usage()
+    sys.exit(1)
+
+if sys.argv[1] == '--user':
+    non_root_users = [x.pw_name for x in pwd.getpwall() if x.pw_name != 'root']
+    print non_root_users[0]
+elif sys.argv[1] == '--group':
+    non_root_groups = [x.gr_name for x in grp.getgrall() if x.gr_name != 'root']
+    print non_root_groups[0]
+else:
+    usage()
+    sys.exit(1)
index e59c1550293ef7879196e50024405ec1e0ec6178..3ce1d553113a3e7d66f656bd075d585584ac1896 100755 (executable)
@@ -44,6 +44,22 @@ force-delete()
     fi
 }
 
+compare-trees()
+{
+    (
+        set -e
+        set -o pipefail
+        tmpfile="$(mktemp)"
+        trap "rm -rf ${tmpfile}" EXIT
+        rsync -ni -aHAX "$1" "$2" > "${tmpfile}"
+        if test $(wc -l < "${tmpfile}") != 0; then
+            echo "ERROR: detected differences between $1 and $2"
+            cat "${tmpfile}"
+            false
+        fi
+    )
+}
+
 test-src-create-extract()
 {
     # Test bup meta create/extract for ./src -> ./src-restore.
@@ -69,17 +85,40 @@ test-src-create-extract()
     )
 }
 
+test-src-save-restore()
+{
+    # Test bup save/restore metadata for ./src -> ./src-restore.  Also
+    # writes to ./src.bup.  Note that for now this just tests the
+    # restore below src/, in order to avoid having to worry about
+    # operations that require root (like chown /home).
+    (
+        set -x
+        rm -rf src.bup
+        mkdir src.bup
+        export BUP_DIR=$(pwd)/src.bup
+        WVPASS bup init
+        WVPASS bup index src
+        WVPASS bup save -t -n src src
+        # Test extract.
+        force-delete src-restore
+        mkdir src-restore
+        WVPASS bup restore -C src-restore "/src/latest$(pwd)/"
+        WVPASS test -d src-restore/src
+        WVPASS compare-trees src/ src-restore/src/
+        rm -rf src.bup
+        set +x
+    )
+}
+
 if actually-root; then
     umount "$TOP/bupmeta.tmp/testfs" || true
 fi
 
-force-delete "$BUP_DIR"
-force-delete "$TOP/bupmeta.tmp"
-
-# Create a test tree.
+setup-test-tree()
 (
     set -e
-    rm -rf "$TOP/bupmeta.tmp/src"
+    force-delete "$BUP_DIR"
+    force-delete "$TOP/bupmeta.tmp"
     mkdir -p "$TOP/bupmeta.tmp/src"
     cp -pPR Documentation cmd lib t "$TOP/bupmeta.tmp"/src
 
@@ -94,8 +133,9 @@ force-delete "$TOP/bupmeta.tmp"
 ) || WVFAIL
 
 # Use the test tree to check bup meta.
-WVSTART 'meta - general'
+WVSTART 'meta --create/--extract'
 (
+    setup-test-tree
     cd "$TOP/bupmeta.tmp"
     test-src-create-extract
 
@@ -107,7 +147,211 @@ WVSTART 'meta - general'
     WVPASS bup meta -xf ../src-file.meta
 )
 
-# Root-only tests: ACLs, Linux attr, Linux xattr, etc.
+# Use the test tree to check bup save/restore metadata.
+WVSTART 'metadata save/restore (general)'
+(
+    setup-test-tree
+    cd "$TOP/bupmeta.tmp"
+    test-src-save-restore
+)
+
+WVSTART 'meta --edit'
+(
+    force-delete "$TOP/bupmeta.tmp"
+    mkdir "$TOP/bupmeta.tmp"
+    cd "$TOP/bupmeta.tmp"
+    mkdir src
+    WVPASS bup meta -cf src.meta src
+
+    WVPASS bup meta --edit --set-uid 0 src.meta | WVPASS bup meta -tvvf - \
+        | WVPASS grep -qE '^uid: 0'
+    WVPASS bup meta --edit --set-uid 1000 src.meta | WVPASS bup meta -tvvf - \
+        | WVPASS grep -qE '^uid: 1000'
+
+    WVPASS bup meta --edit --set-gid 0 src.meta | WVPASS bup meta -tvvf - \
+        | WVPASS grep -qE '^gid: 0'
+    WVPASS bup meta --edit --set-gid 1000 src.meta | WVPASS bup meta -tvvf - \
+        | WVPASS grep -qE '^gid: 1000'
+
+    WVPASS bup meta --edit --set-user foo src.meta | WVPASS bup meta -tvvf - \
+        | WVPASS grep -qE '^user: foo'
+    WVPASS bup meta --edit --set-user bar src.meta | WVPASS bup meta -tvvf - \
+        | WVPASS grep -qE '^user: bar'
+    WVPASS bup meta --edit --unset-user src.meta | WVPASS bup meta -tvvf - \
+        | WVPASS grep -qE '^user:'
+    WVPASS bup meta --edit --set-user bar --unset-user src.meta \
+        | WVPASS bup meta -tvvf - | WVPASS grep -qE '^user:'
+    WVPASS bup meta --edit --unset-user --set-user bar src.meta \
+        | WVPASS bup meta -tvvf - | WVPASS grep -qE '^user: bar'
+
+    WVPASS bup meta --edit --set-group foo src.meta | WVPASS bup meta -tvvf - \
+        | WVPASS grep -qE '^group: foo'
+    WVPASS bup meta --edit --set-group bar src.meta | WVPASS bup meta -tvvf - \
+        | WVPASS grep -qE '^group: bar'
+    WVPASS bup meta --edit --unset-group src.meta | WVPASS bup meta -tvvf - \
+        | WVPASS grep -qE '^group:'
+    WVPASS bup meta --edit --set-group bar --unset-group src.meta \
+        | WVPASS bup meta -tvvf - | WVPASS grep -qE '^group:'
+    WVPASS bup meta --edit --unset-group --set-group bar src.meta \
+        | WVPASS bup meta -tvvf - | grep -qE '^group: bar'
+)
+
+# Test ownership restoration (when not root or fakeroot).
+(
+    if test "$(whoami)" == root; then
+        exit 0
+    fi
+
+    WVSTART 'metadata (restoration of ownership)'
+    force-delete "$TOP/bupmeta.tmp"
+    mkdir "$TOP/bupmeta.tmp"
+    cd "$TOP/bupmeta.tmp"
+    touch src
+    WVPASS bup meta -cf src.meta src
+
+    mkdir dest
+    cd dest
+    # Make sure we don't change (or try to change) the user when not root.
+    WVPASS bup meta --edit --set-user root ../src.meta | WVPASS bup meta -x
+    WVPASS bup xstat src | WVPASS grep -qvE '^user: root'
+    rm -rf src
+    WVPASS bup meta --edit --unset-user --set-uid 0 ../src.meta \
+        | WVPASS bup meta -x
+    WVPASS bup xstat src | grep -qvE '^user: root'
+
+    # Make sure we can restore one of the user's groups.
+    user_groups="$(groups)"
+    last_group="$(echo ${user_groups/* /})"
+    rm -rf src
+    WVPASS bup meta --edit --set-group "$last_group" ../src.meta \
+        | WVPASS bup meta -x
+    WVPASS bup xstat src | WVPASS grep -qE "^group: $last_group"
+
+    # Make sure we can restore one of the user's gids.
+    user_gids="$(id -G)"
+    last_gid="$(echo ${user_gids/* /})"
+    rm -rf src
+    WVPASS bup meta --edit --unset-group --set-gid "$last_gid" ../src.meta \
+        | WVPASS bup meta -x
+    WVPASS bup xstat src | WVPASS grep -qE "^gid: $last_gid"
+
+    # Test --numeric-ids (gid).
+    rm -rf src
+    current_gidx=$(bup meta -tvvf ../src.meta | grep -e '^gid:')
+    WVPASS bup meta --edit --set-group "$last_group" ../src.meta \
+        | WVPASS bup meta -x --numeric-ids
+    new_gidx=$(bup xstat src | grep -e '^gid:')
+    WVPASSEQ "$current_gidx" "$new_gidx"
+
+    # Test that restoring an unknown user works.
+    unknown_user=$("$TOP"/t/unknown-owner --user)
+    rm -rf src
+    current_uidx=$(bup meta -tvvf ../src.meta | grep -e '^uid:')
+    WVPASS bup meta --edit --set-user "$unknown_user" ../src.meta \
+        | WVPASS bup meta -x
+    new_uidx=$(bup xstat src | grep -e '^uid:')
+    WVPASSEQ "$current_uidx" "$new_uidx"
+
+    # Test that restoring an unknown group works.
+    unknown_group=$("$TOP"/t/unknown-owner --group)
+    rm -rf src
+    current_gidx=$(bup meta -tvvf ../src.meta | grep -e '^gid:')
+    WVPASS bup meta --edit --set-group "$unknown_group" ../src.meta \
+        | WVPASS bup meta -x
+    new_gidx=$(bup xstat src | grep -e '^gid:')
+    WVPASSEQ "$current_gidx" "$new_gidx"
+)
+
+# Test ownership restoration (when root or fakeroot).
+(
+    if test "$(whoami)" != root; then
+        exit 0
+    fi
+
+    WVSTART 'metadata (restoration of ownership as root)'
+    force-delete "$TOP/bupmeta.tmp"
+    mkdir "$TOP/bupmeta.tmp"
+    cd "$TOP/bupmeta.tmp"
+    touch src
+    WVPASS bup meta -cf src.meta src
+
+    mkdir dest
+    chmod 700 dest # so we can't accidentally do something insecure
+    cd dest
+
+    # Make sure we can restore a uid.
+    WVPASS bup meta --edit --unset-user --set-uid 42 ../src.meta \
+        | WVPASS bup meta -x
+    WVPASS bup xstat src | WVPASS grep -qE '^uid: 42'
+
+    # Make sure we can restore a gid.
+    WVPASS bup meta --edit --unset-group --set-gid 42 ../src.meta \
+        | WVPASS bup meta -x
+    WVPASS bup xstat src | WVPASS grep -qE '^gid: 42'
+
+    some_user=$("$TOP"/t/some-owner --user)
+    some_group=$("$TOP"/t/some-owner --group)
+
+    # Try to restore a user (and see that user trumps uid when uid is not 0).
+    WVPASS bup meta --edit --set-uid 42 --set-user "$some_user" ../src.meta \
+        | WVPASS bup meta -x
+    WVPASS bup xstat src | WVPASS grep -qE "^user: $some_user"
+
+    # Try to restore a group (and see that group trumps gid when gid is not 0).
+    WVPASS bup meta --edit --set-gid 42 --set-group "$some_group" ../src.meta \
+        | WVPASS bup meta -x
+    WVPASS bup xstat src | WVPASS grep -qE "^group: $some_user"
+
+    # Make sure a uid of 0 trumps a non-root user.
+    WVPASS bup meta --edit --set-user "$some_user" ../src.meta \
+        | WVPASS bup meta -x
+    WVPASS bup xstat src | WVPASS grep -qvE "^user: $some_user"
+    WVPASS bup xstat src | WVPASS grep -qE "^uid: 0"
+
+    # Make sure a gid of 0 trumps a non-root group.
+    WVPASS bup meta --edit --set-group "$some_user" ../src.meta \
+        | WVPASS bup meta -x
+    WVPASS bup xstat src | WVPASS grep -qvE "^group: $some_group"
+    WVPASS bup xstat src | WVPASS grep -qE "^gid: 0"
+
+    # Test --numeric-ids (gid).  Note the name 'root' is not handled
+    # specially, so we use that here as the test group name.  We
+    # assume that the root group's gid is never 42.
+    rm -rf src
+    WVPASS bup meta --edit --set-group root --set-gid 42 ../src.meta \
+        | WVPASS bup meta -x --numeric-ids
+    new_gidx=$(bup xstat src | grep -e '^gid:')
+    WVPASSEQ "$new_gidx" 'gid: 42'
+
+    # Test --numeric-ids (uid).  Note the name 'root' is not handled
+    # specially, so we use that here as the test user name.  We assume
+    # that the root user's uid is never 42.
+    rm -rf src
+    WVPASS bup meta --edit --set-user root --set-uid 42 ../src.meta \
+        | WVPASS bup meta -x --numeric-ids
+    new_uidx=$(bup xstat src | grep -e '^uid:')
+    WVPASSEQ "$new_uidx" 'uid: 42'
+
+    # Test that restoring an unknown user works.
+    unknown_user=$("$TOP"/t/unknown-owners --user)
+    rm -rf src
+    WVPASS bup meta --edit --set-uid 42 --set-user "$unknown_user" ../src.meta \
+        | WVPASS bup meta -x
+    new_uidx=$(bup xstat src | grep -e '^uid:')
+    WVPASSEQ "$new_uidx" 'uid: 42'
+
+    # Test that restoring an unknown group works.
+    unknown_group=$("$TOP"/t/unknown-owners --group)
+    rm -rf src
+    WVPASS bup meta --edit \
+        --set-gid 42 --set-group "$unknown_group" ../src.meta \
+        | WVPASS bup meta -x
+    new_gidx=$(bup xstat src | grep -e '^gid:')
+    WVPASSEQ "$new_gidx" 'gid: 42'
+)
+
+# Root-only tests that require an FS with all the trimmings: ACLs,
+# Linux attr, Linux xattr, etc.
 if actually-root; then
     (
         # These tests are only likely to work under Linux for now
@@ -123,7 +367,9 @@ if actually-root; then
         trap cleanup_at_exit EXIT
 
         WVSTART 'meta - general (as root)'
-        WVPASS cd "$TOP/bupmeta.tmp"
+        setup-test-tree
+        cd "$TOP/bupmeta.tmp"
+
         umount testfs || true
         dd if=/dev/zero of=testfs.img bs=1M count=32
         mke2fs -F -j -m 0 testfs.img
diff --git a/t/unknown-owner b/t/unknown-owner
new file mode 100755 (executable)
index 0000000..212d146
--- /dev/null
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+
+import grp
+import pwd
+import sys
+
+def usage():
+    print >> sys.stderr, "Usage: unknown-owners (--user | --group)"
+
+if len(sys.argv) != 2:
+    usage()
+    sys.exit(1)
+
+if sys.argv[1] == '--user':
+    max_name_len = max([len(x.pw_name) for x in pwd.getpwall()])
+elif sys.argv[1] == '--group':
+    max_name_len = max([len(x.gr_name) for x in grp.getgrall()])
+else:
+    usage()
+    sys.exit(1)
+
+print 'x' * (max_name_len + 1)