Patchwork [21,of,21,RFC] hgext/censor: introduce censor command enabling file history to expunged

login
register
mail settings
Submitter michaeljedgar@gmail.com
Date Sept. 11, 2014, 12:26 a.m.
Message ID <37edd2b0f7390ad69f92.1410395182@adgar-macbookpro3.roam.corp.google.com>
Download mbox | patch
Permalink /patch/5796/
State Changes Requested
Headers show

Comments

michaeljedgar@gmail.com - Sept. 11, 2014, 12:26 a.m.
# HG changeset patch
# User Mike Edgar <adgar@google.com>
# Date 1410323991 14400
#      Wed Sep 10 00:39:51 2014 -0400
# Node ID 37edd2b0f7390ad69f92625e76dd6615679a4bfd
# Parent  eff2398c409bcd7b70335f57563095fb63adf270
hgext/censor: introduce censor command enabling file history to expunged

The initial implementation accepts a file and a single revision to censor
using revset syntax. The command rewrites the file's revlog, replacing the
requested revision with a special tombstone.

This implementation only supports the v0revlog format with inline data.

Patch

diff -r eff2398c409b -r 37edd2b0f739 hgext/censor.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hgext/censor.py	Wed Sep 10 00:39:51 2014 -0400
@@ -0,0 +1,182 @@ 
+# Copyright (C) 2014 - Mike Edgar <adgar@google.com>
+#
+# This extension enables removal of file content at a given revision,
+# rewriting the data/metadata of successive revisions to preserve revision log
+# integrity.
+
+"""erase file content at a given revision
+
+The censor command instructs Mercurial to erase all content of a file at a given
+revision *without updating the changeset hash.* This allows existing history to
+remain valid while preventing future clones/pulls from receiving the erased
+data.
+
+Typical uses for censor are due to security or legal requirements, including::
+
+ * Passwords, private keys, crytographic material
+ * Licensed data/code/libraries for which the license has expired
+ * Personally Identifiable Information or other private data
+
+Censored file revisions are listed in a tracked file called .hgcensored stored
+in the repository root. The censor command adds an entry to the .hgcensored file
+in the working directory and commits it (much like ``hg tag`` and .hgtags). The
+censored file data is then replaced with a pointer to the new commit, enabling
+verification.
+
+Censored nodes can interrupt mercurial's typical operation whenever the excised
+data needs to be materialized. Some commands, like ``hg cat``/``hg revert``,
+simply fail when asked to produce censored data. Others, like ``hg verify`` and
+``hg update``, must be capable of tolerating censored data to continue to
+function in a meaningful way. Such commands only tolerate censored file
+revisions if they are allowed by the policy specified by the "censor.allow"
+config option.
+"""
+
+from mercurial.node import hex, short, nullid, nullrev
+from mercurial import censor as censormod
+from mercurial import commands, cmdutil, error, filelog, revlog, scmutil, util
+from mercurial.i18n import _
+
+cmdtable = {}
+command = cmdutil.command(cmdtable)
+testedwith = 'internal'
+
+@command('censor',
+    [('r', 'rev', '', _('censor file from specified revision'), _('REV')),
+    ('m', 'message', '', _('use text as commit message'), _('TEXT')),
+    ] + commands.commitopts2,
+    _('[-m TEXT] [-d DATE] [-u USER] [-r REV] [FILE]'))
+def censor(ui, repo, path, rev='', message='', **opts):
+    if not path:
+        raise util.Abort(_('must specify file path to censor'))
+    if not rev:
+        raise util.Abort(_('must specify revision to censor'))
+    if not message:
+        raise util.Abort(_('must specify a message to justify censorship'))
+
+    flog = repo.file(path)
+    if not len(flog):
+        raise util.Abort(_('cannot censor file with no history'))
+
+    rev = scmutil.revsingle(repo, rev, rev).rev()
+    try:
+        ctx = repo[rev]
+    except KeyError:
+        raise util.Abort(_('invalid revision identifier %s') % rev)
+
+    try:
+        fctx = ctx.filectx(path)
+    except error.LookupError:
+        raise util.Abort(_('file does not exist at revision %s') % rev)
+
+    fnode = fctx.filenode()
+    headctxs = [repo[c] for c in repo.heads()]
+    heads = [c for c in headctxs if path in c and c.filenode(path) == fnode]
+    if heads:
+        headlist = ', '.join([short(c.node()) for c in heads])
+        raise util.Abort(_('cannot censor file in heads (%s)') % headlist,
+            hint=_('clean/delete and commit first'))
+
+    wctx = repo[None]
+    wp = wctx.parents()
+    if ctx.node() in [p.node() for p in wp]:
+        raise util.Abort(_('cannot censor working directory'),
+            hint=_('clean/delete/update first'))
+
+    flogv = flog.version & 0xFFFF
+    if flogv != revlog.REVLOGNG:
+        raise util.Abort(
+            _('censor does not support revlog version %d') % (flogv,))
+
+    date = opts.get('date')
+    if date:
+        date = util.parsedate(date)
+    tombstone = censormod.record(repo, path, fctx.filenode(), message,
+                                 opts.get('user'), date)
+
+    if flog.version & revlog.REVLOGNGINLINEDATA:
+        _censor_inline(repo, flog, fctx.filerev(), tombstone)
+    else:
+        raise util.Abort(_('censor currently only supports inline revlogs'))
+
+def _censor_inline(repo, flog, crev, tombstone):
+    # Using two files instead of one makes it easy to rewrite entry-by-entry
+    ifp = flog.opener(flog.indexfile, 'r')
+    ofp = flog.opener(flog.indexfile, 'wb', atomictemp=True)
+
+    rio = revlog.revlogio()
+
+    def reindex(r, noffset, ncomp=None, nuncomp=None):
+        """Recomputes the index entry for the given revision at a new offset.
+
+        If ncomp and nuncomp are both provided, the new index entry will be
+        for a full-text revision with the given compressed/uncompressed sizes.
+
+        Args:
+            r: int, the filelog revision number to reindex
+            noffset: int, the new data offset
+            ncomp: int, the compressed size of the full text of the revision
+                data which will be found at noffset
+            nuncomp: int, the uncompressed size of the full text of the revision
+                data
+        Returns:
+            A string representing the recomputed index entry in revlogng format.
+        """
+        offlags, comp, uncomp, base, link, p1, p2, nodeid = flog.index[r]
+        offlags = revlog.offset_type(noffset, revlog.gettype(offlags))
+        if ncomp and nuncomp:
+            comp = ncomp
+            uncomp = nuncomp
+            base = r
+        e = (offlags, comp, uncomp, base, link, p1, p2, nodeid)
+        return rio.packentry(e, None, flog.version, r)
+
+    # Copy all revlog data up to the entry to be censored.
+    offset = flog.start(crev)
+    ioffs = offset + crev * rio.size
+
+    for chunk in util.filechunkiter(ifp, limit=ioffs):
+        ofp.write(chunk)
+
+    def writefull(r, offs, data):
+        """Write the given fulltext revision to a revlog.
+
+        Args:
+            r: int, the filelog revision to write as full text
+            offs: int, the data offset at which to write the full revision text
+            data: string, the full text of the revision to write
+        Returns:
+            The integer number of data bytes written, for tracking data offsets.
+        """
+        flag, compdata = flog.compress(data)
+        complen = len(flag) + len(compdata)
+        ofp.write(reindex(r, offs, complen, len(data)))
+        ofp.write(flag)
+        ofp.write(compdata)
+        ifp.seek(rio.size + flog.length(r), 1)
+        return complen
+
+    # Write censored revlog entry.
+    offset += writefull(crev, offset, tombstone)
+
+    # Rewrite all following filelog revisions to fixup offsets and deltas.
+    for srev in xrange(crev + 1, len(flog)):
+        if crev in flog.parentrevs(srev):
+            # Immediate children of censored node must be re-added as fulltext.
+            try:
+                data = flog.revision(srev)
+            except error.CensoredNodeError:
+                data = flog.censormeta(srev)
+            dlen = writefull(srev, offset, data)
+        else:
+            # Copy any other revision data verbatim after fixing up the
+            # index offset.
+            ofp.write(reindex(srev, offset))
+            ifp.seek(rio.size, 1)
+            dlen = flog.length(srev)
+            for chunk in util.filechunkiter(ifp, limit=dlen):
+                ofp.write(chunk)
+        offset += dlen
+
+    ifp.close()
+    ofp.close()
diff -r eff2398c409b -r 37edd2b0f739 tests/test-censor.t
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-censor.t	Wed Sep 10 00:39:51 2014 -0400
@@ -0,0 +1,522 @@ 
+  $ cat >> $HGRCPATH <<EOF
+  > [extensions]
+  > censor=
+  > EOF
+  $ cp $HGRCPATH $HGRCPATH.orig
+
+Create repo with unimpeachable content
+
+  $ hg init r
+  $ cd r
+  $ echo 'Initially untainted file' > target
+  $ echo 'Normal file here' > bystander
+  $ hg add target bystander
+  $ hg ci -m init
+
+Clone repo so we can test pull later
+
+  $ cd ..
+  $ hg clone r rpull
+  updating to branch default
+  2 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ cd r
+
+Introduce content which will ultimately require censorship
+
+  $ echo 'Tainted file' > target
+  $ echo 'Passwords: hunter2' >> target
+  $ hg ci -m taint target
+
+  $ echo 'hunter3' >> target
+  $ echo 'Normal file v2' > bystander
+  $ hg ci -m moretaint target bystander
+
+Add a new sanitized version to correct our mistake
+
+  $ echo 'Tainted file is now sanitized' > target
+  $ hg ci -m sanitized target
+
+Add a second sanitized version with the same parent
+
+  $ hg bookmarks b1
+  $ hg update 'tip^'
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (leaving bookmark b1)
+  $ echo 'Tainted file now super sanitized' > target
+  $ hg ci -m 'super sanitized' target
+  created new head
+  $ hg bookmarks b2
+
+Verify target contents before censorship at each revision
+
+  $ hg cat -r b1 target
+  Tainted file is now sanitized
+  $ hg cat -r b2 target
+  Tainted file now super sanitized
+  $ hg cat -r 'tip^' target
+  Tainted file
+  Passwords: hunter2
+  hunter3
+  $ hg cat -r 'tip^^' target
+  Tainted file
+  Passwords: hunter2
+  $ hg cat -r 'tip^^^' target
+  Initially untainted file
+
+Censor revision with 2 offenses
+
+  $ hg censor -r 'b1^' target -m 'censor two passwords' -d '2014-08-28 11:22:33 -0800'
+  $ hg cat -r b1 target
+  Tainted file is now sanitized
+  $ hg cat -r b2 target
+  Tainted file now super sanitized
+  $ hg cat -r 'b1^' target
+  abort: node 1e0247a9a4b7 in file target is censored
+  [255]
+  $ hg cat -r 'b1^^' target
+  Tainted file
+  Passwords: hunter2
+  $ hg cat -r 'b1^^^' target
+  Initially untainted file
+
+Censor revision with 1 offense
+
+  $ hg censor -r 'b1^^' target -m 'censor one password' -d '2014-08-28 11:44:55 -0800'
+  $ hg cat -r b1 target
+  Tainted file is now sanitized
+  $ hg cat -r b2 target
+  Tainted file now super sanitized
+  $ hg cat -r 2 target
+  abort: node 1e0247a9a4b7 in file target is censored
+  [255]
+  $ hg cat -r 1 target
+  abort: node 613bc869fceb in file target is censored
+  [255]
+  $ hg cat -r 0 target
+  Initially untainted file
+
+Can only checkout target at uncensored revisions, -X is workaround for --all
+
+  $ hg revert -r 'b1^' target
+  abort: file target is censored at b2ae1465d532, cannot checkout
+  [255]
+  $ hg revert -r 'b1^^' target
+  abort: file target is censored at 186fb27560c3, cannot checkout
+  [255]
+  $ hg revert -r 'b1^^' --all
+  removing .hgcensored
+  reverting bystander
+  reverting target
+  abort: file target is censored at 186fb27560c3, cannot checkout
+  [255]
+  $ hg revert -r 'b1^^' --all -X target
+  $ cat target
+  Tainted file now super sanitized
+  $ hg revert -r 'b1^^^' --all
+  reverting target
+  $ cat target
+  Initially untainted file
+  $ hg revert -r b2 --all
+  undeleting .hgcensored
+  reverting bystander
+  reverting target
+  $ cat target
+  Tainted file now super sanitized
+
+Uncensored file can be viewed at any revision
+
+  $ hg cat -r 3 bystander
+  Normal file v2
+  $ hg cat -r 2 bystander
+  Normal file v2
+  $ hg cat -r 1 bystander
+  Normal file here
+  $ hg cat -r 0 bystander
+  Normal file here
+
+Can update to children of censored revision
+
+  $ hg update b1
+  1 files updated, 0 files merged, 1 files removed, 0 files unresolved
+  (activating bookmark b1)
+  $ cat target
+  Tainted file is now sanitized
+  $ hg update b2
+  2 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (activating bookmark b2)
+  $ cat target
+  Tainted file now super sanitized
+
+Require signed censorship commits in trusted $HGRC so hg verify fails
+
+  $ cp $HGRCPATH.orig $HGRCPATH
+  $ cat >> $HGRCPATH <<EOF
+  > [censor]
+  > allow = signed
+  > EOF
+
+Repo fails verification due to censorship
+
+  $ hg verify
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+   1: node 613bc869fceb censored from target
+   2: node 1e0247a9a4b7 censored from target
+  3 files, 7 changesets, 9 total revisions
+  2 integrity errors encountered!
+  (first damaged changeset appears to be 1)
+  [1]
+
+Cannot update to revision with censored data
+
+  $ hg update 2
+  abort: file target censored at revision b2ae1465d532
+  [255]
+  $ hg update 1
+  abort: file target censored at revision 186fb27560c3
+  [255]
+  $ hg update 0
+  2 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (leaving bookmark b2)
+  $ hg update b2
+  3 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (activating bookmark b2)
+
+Allow censorship without validation in trusted $HGRC so hg verify passes
+
+  $ cp $HGRCPATH.orig $HGRCPATH
+  $ cat >> $HGRCPATH <<EOF
+  > [censor]
+  > allow = hgcensor
+  > EOF
+
+Repo passes verification with warnings with explicit config
+
+  $ hg verify
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+  warning: node 613bc869fceb censored from target
+  warning: node 1e0247a9a4b7 censored from target
+  3 files, 7 changesets, 9 total revisions
+  2 warnings encountered!
+
+May update to revision with censored data with explicit config
+
+  $ hg update 2
+  warning: censored file is empty: target
+  1 files updated, 0 files merged, 1 files removed, 0 files unresolved
+  (leaving bookmark b2)
+  $ cat target
+  $ hg update 1
+  warning: censored file is empty: target
+  2 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ cat target
+  $ hg update 0
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ cat target
+  Initially untainted file
+  $ hg update b2
+  3 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (activating bookmark b2)
+  $ cat target
+  Tainted file now super sanitized
+
+Cannot merge in revision with censored data
+
+  $ hg update b1
+  1 files updated, 0 files merged, 1 files removed, 0 files unresolved
+  (activating bookmark b1)
+  $ echo 'b1 clean head' > target
+  $ hg ci -m 'clean head for b1' target
+  $ hg censor -r 'b1^' target -m 'no good reason really' -d '2014-08-28 22:33:44 -0800'
+  $ hg update b2
+  2 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (activating bookmark b2)
+  $ hg merge -r 'b1^'
+  abort: censored node 1e0247a9a4b7!
+  [255]
+
+Revisions present in repository heads may not be censored
+
+  $ hg update -C b2
+  0 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (activating bookmark b2)
+  $ hg bookmark b3
+  $ hg censor -r b3 target -m 'bad content at head'
+  abort: cannot censor file in heads (c9b11ca51575)
+  (clean/delete and commit first)
+  [255]
+  $ echo 'twiddling thumbs' > bystander
+  $ hg ci -m 'bystander commit'
+  $ hg censor -r 'b3^' target -m 'bad content at head'
+  abort: cannot censor file in heads (f62edcb67fd3)
+  (clean/delete and commit first)
+  [255]
+
+Cannot censor working directory
+
+  $ echo 'seriously no passwords' > target
+  $ hg ci -m 'extend b3 arbitrarily' target
+  $ hg update 'b3^'
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (leaving bookmark b3)
+  $ hg censor -r 'b3^' target -m 'bad content in working directory'
+  abort: cannot censor working directory
+  (clean/delete/update first)
+  [255]
+  $ hg update b3
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (activating bookmark b3)
+
+Can re-add file after being deleted + censored
+
+  $ hg rm target
+  $ hg ci -m 'delete target so it may be censored'
+  $ hg censor -r 'b3^' target -m 'trying to censor a deletion' -d '2000-01-01 00:00:00 +0000'
+  $ hg cat -r 'b3^' target
+  target: no such file in rev d159fe213f85
+  [1]
+  $ hg cat -r 'b3^^' target
+  abort: node ee84e09d5a88 in file target is censored
+  [255]
+  $ echo 'fresh start' > target
+  $ hg add target
+  $ hg ci -m reincarnated target
+  $ hg cat -r b3 target
+  fresh start
+  $ hg cat -r 'b3^' target
+  target: no such file in rev 6ef15ea426ee
+  [1]
+  $ hg cat -r 'b3^^' target
+  target: no such file in rev d159fe213f85
+  [1]
+  $ hg cat -r 'b3^^^' target
+  abort: node ee84e09d5a88 in file target is censored
+  [255]
+  $ hg update 'b2'
+  3 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (activating bookmark b2)
+
+Repo with censored nodes can be cloned and cloned nodes are censored
+
+  $ cd ..
+  $ hg clone r rclone
+  updating to branch default
+  3 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ cd rclone
+  $ hg cat -r b1 target
+  b1 clean head
+  $ hg cat -r b2 target
+  Tainted file now super sanitized
+  $ hg cat -r 2 target
+  abort: node 1e0247a9a4b7 in file target is censored
+  [255]
+  $ hg cat -r 1 target
+  abort: node 613bc869fceb in file target is censored
+  [255]
+  $ hg cat -r 0 target
+  Initially untainted file
+  $ hg verify
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+  warning: node 613bc869fceb censored from target
+  warning: node 1e0247a9a4b7 censored from target
+  warning: node 6c266b519681 censored from target
+  warning: node ee84e09d5a88 censored from target
+  3 files, 14 changesets, 15 total revisions
+  4 warnings encountered!
+
+Repo cloned before tainted content introduced can pull censored nodes
+
+  $ cd ../rpull
+  $ hg cat -r tip target
+  Initially untainted file
+  $ hg verify
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+  2 files, 1 changesets, 2 total revisions
+  $ hg pull -B b1 -B b2 -B b3
+  pulling from $TESTTMP/r (glob)
+  searching for changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 13 changesets with 13 changes to 3 files (+1 heads)
+  adding remote bookmark b1
+  adding remote bookmark b2
+  adding remote bookmark b3
+  (run 'hg heads' to see heads, 'hg merge' to merge)
+  importing bookmark b1
+  importing bookmark b2
+  importing bookmark b3
+  $ hg update b2
+  3 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (activating bookmark b2)
+  $ cat target
+  Tainted file now super sanitized
+  $ hg cat -r b1 target
+  b1 clean head
+  $ hg cat -r b2 target
+  Tainted file now super sanitized
+  $ hg cat -r 2 target
+  abort: node 1e0247a9a4b7 in file target is censored
+  [255]
+  $ hg cat -r 1 target
+  abort: node 613bc869fceb in file target is censored
+  [255]
+  $ hg cat -r 0 target
+  Initially untainted file
+  $ hg verify
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+  warning: node 613bc869fceb censored from target
+  warning: node 1e0247a9a4b7 censored from target
+  warning: node 6c266b519681 censored from target
+  warning: node ee84e09d5a88 censored from target
+  3 files, 14 changesets, 15 total revisions
+  4 warnings encountered!
+
+Censored nodes can be pushed if they censor previously unexchanged nodes
+
+  $ echo 'Passwords: hunter2hunter2' > target
+  $ hg ci -m 're-add password from clone' target
+  created new head
+  $ REV=`hg id --debug -i`
+  $ echo 'Re-sanitized; nothing to see here' > target
+  $ hg ci -m 're-sanitized' target
+  $ CLEANREV=`hg id --debug -i`
+  $ hg cat -r $REV target
+  Passwords: hunter2hunter2
+  $ hg censor -r $REV target -m 'censor doubly-secure password' -d '2014-08-29 00:00:00 -0800'
+  $ hg cat -r $REV target
+  abort: node d09db1c45bd3 in file target is censored
+  [255]
+  $ hg cat -r $CLEANREV target
+  Re-sanitized; nothing to see here
+  $ hg push -B b1 -B b2 -B b3
+  pushing to $TESTTMP/r (glob)
+  searching for changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 3 changesets with 3 changes to 2 files (+1 heads)
+  updating bookmark b2
+  exporting bookmark b1
+  exporting bookmark b2
+  exporting bookmark b3
+
+  $ cd ../r
+  $ hg cat -r $REV target
+  abort: node d09db1c45bd3 in file target is censored
+  [255]
+  $ hg cat -r $CLEANREV target
+  Re-sanitized; nothing to see here
+  $ hg update $CLEANREV
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (leaving bookmark b2)
+  $ cat target
+  Re-sanitized; nothing to see here
+
+Censored nodes can be bundled up and unbundled in another repo
+
+  $ hg bundle --base 0 ../pwbundle
+  16 changesets found
+  $ cd ../rclone
+  $ hg unbundle ../pwbundle
+  adding changesets
+  adding manifests
+  adding file changes
+  added 3 changesets with 3 changes to 3 files (+1 heads)
+  (run 'hg heads .' to see heads, 'hg merge' to merge)
+  $ hg cat -r $REV target
+  abort: node d09db1c45bd3 in file target is censored
+  [255]
+  $ hg cat -r $CLEANREV target
+  Re-sanitized; nothing to see here
+  $ hg update $CLEANREV
+  3 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ cat target
+  Re-sanitized; nothing to see here
+  $ hg verify
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+  warning: node 613bc869fceb censored from target
+  warning: node 1e0247a9a4b7 censored from target
+  warning: node 6c266b519681 censored from target
+  warning: node ee84e09d5a88 censored from target
+  warning: node d09db1c45bd3 censored from target
+  3 files, 17 changesets, 18 total revisions
+  5 warnings encountered!
+
+Censored nodes can be imported on top of censored nodes, consecutively
+
+  $ hg init ../rimport
+  $ hg bundle --base 1 ../rimport/splitbundle
+  15 changesets found
+  $ cd ../rimport
+  $ hg pull -r 1 -B b1 -B b2 -B b3 ../r
+  pulling from ../r
+  adding changesets
+  adding manifests
+  adding file changes
+  added 17 changesets with 18 changes to 3 files (+2 heads)
+  adding remote bookmark b1
+  adding remote bookmark b2
+  adding remote bookmark b3
+  (run 'hg heads' to see heads, 'hg merge' to merge)
+  importing bookmark b1
+  importing bookmark b2
+  importing bookmark b3
+  $ hg unbundle splitbundle
+  adding changesets
+  adding manifests
+  adding file changes
+  added 0 changesets with 0 changes to 3 files
+  (run 'hg update' to get a working copy)
+  $ hg update b2
+  3 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  (activating bookmark b2)
+  $ cat target
+  Re-sanitized; nothing to see here
+  $ hg verify
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+  warning: node 613bc869fceb censored from target
+  warning: node 1e0247a9a4b7 censored from target
+  warning: node 6c266b519681 censored from target
+  warning: node ee84e09d5a88 censored from target
+  warning: node d09db1c45bd3 censored from target
+  3 files, 17 changesets, 18 total revisions
+  5 warnings encountered!
+  $ cd ../r
+
+Can import bundle where first revision of a file is censored
+
+  $ hg init ../rinit
+  $ hg censor -r 0 target -m 'first revision is suspicious' -d '2014-08-29 01:02:03 -0800'
+  $ hg bundle -r 0 --base null ../rinit/initbundle
+  1 changesets found
+  $ cd ../rinit
+  $ hg unbundle initbundle
+  adding changesets
+  adding manifests
+  adding file changes
+  added 1 changesets with 2 changes to 2 files
+  (run 'hg update' to get a working copy)
+  $ hg cat -r 0 target
+  abort: node 14b47fa43d12 in file target is censored
+  [255]
diff -r eff2398c409b -r 37edd2b0f739 tests/test-help.t
--- a/tests/test-help.t	Wed Sep 10 00:39:31 2014 -0400
+++ b/tests/test-help.t	Wed Sep 10 00:39:51 2014 -0400
@@ -247,6 +247,7 @@ 
        acl           hooks for controlling repository access
        blackbox      log repository events to a blackbox for debugging
        bugzilla      hooks for integrating with the Bugzilla bug tracker
+       censor        erase file content at a given revision
        churn         command to display statistics about repository history
        color         colorize output from some commands
        convert       import revisions from foreign VCS repositories into