diff mbox series

[09/10] fast-export: allow seeding the anonymized mapping

Message ID 20200623152505.GI1435482@coredump.intra.peff.net (mailing list archive)
State New, archived
Headers show
Series fast-export: allow seeding the anonymized mapping | expand

Commit Message

Jeff King June 23, 2020, 3:25 p.m. UTC
After you anonymize a repository, it can be hard to find which commits
correspond between the original and the result, and thus hard to
reproduce commands that triggered bugs in the original.

Let's make it possible to seed the anonymization map. This lets users
either:

  - mark names to be retained as-is, if they don't consider them secret
    (in which case their original commands would just work)

  - map names to new values, which lets them adapt the reproduction
    recipe to the new names without revealing the originals

The implementation is fairly straight-forward. We already store each
anonymized token in a hashmap (so that the same token appearing twice is
converted to the same result). We can just introduce a new "seed"
hashmap which is consulted first.

This does make a few more promises to the user about how we'll anonymize
things (e.g., token-splitting pathnames). But it's unlikely that we'd
want to change those rules, even if the actual anonymization of a single
token changes. And it makes things much easier for the user, who can
unblind only a directory name without having to specify each path within
it.

One alternative to this approach would be to anonymize as we see fit,
and then dump the whole refname and pathname mappings to a file. This
does work, but it's a bit awkward to use (you have to manually dig the
items you care about out of the mapping).

Signed-off-by: Jeff King <peff@peff.net>
---
 Documentation/git-fast-export.txt | 24 ++++++++++++++++
 builtin/fast-export.c             | 47 ++++++++++++++++++++++++++++++-
 t/t9351-fast-export-anonymize.sh  | 11 +++++++-
 3 files changed, 80 insertions(+), 2 deletions(-)

Comments

Eric Sunshine June 23, 2020, 5:16 p.m. UTC | #1
On Tue, Jun 23, 2020 at 11:25 AM Jeff King <peff@peff.net> wrote:
> diff --git a/Documentation/git-fast-export.txt b/Documentation/git-fast-export.txt
> @@ -238,6 +243,25 @@ collapse "User 0", "User 1", etc into "User X"). This produces a much
> +[...] For example, if you have a bug which reproduces
> +with `git rev-list mybranch -- foo.c`, you can run:
> +
> +---------------------------------------------------
> +$ git fast-export --anonymize --all \
> +   --seed-anonymized=foo.c:secret.c \
> +   --seed-anonymized=mybranch \
> +   >stream
> +---------------------------------------------------
> +
> +After importing the stream, you can then run `git rev-list mybranch --
> +secret.c` in the anonymized repository.

I understand that your intention here is to demonstrate both forms of
--seed-anonymized, but I'm slightly concerned that people may
interpret this example as meaning that you are not allowed to
anonymize the refname when anonymizing a pathname. It might be less
ambiguous to avoid the "short form" in the example; people who have
read the description of --seed-anonymized will know that the short
form can be used without having to see it in an example.

> +Note that paths and refnames are split into tokens at slash boundaries.
> +The command above would anonymize `subdir/foo.c` as something like
> +`path123/secret.c`.

Confusing. This seems to be saying that anonymizing filenames in
subdirectories is pointless because you can't know how the leading
directory names will be anonymized. That leaves the reader wondering
how to deal with the situation. Does it require using
--seed-anonymized for each path component leading up to the filename?
Or can --seed-anonymized take an full pathname (leading directory
components and filename) in one shot?

> @@ -168,8 +169,18 @@ static const char *anonymize_str(struct hashmap *map,
> -    ret = hashmap_get_entry(map, &key, hash, &key);
>
> +    /* First check if it's a token the user configured manually... */
> +    if (anonymized_seeds.cmpfn)
> +        ret = hashmap_get_entry(&anonymized_seeds, &key, hash, &key);
> +    else
> +        ret = NULL;
> +
> +    /* ...otherwise check if we've already seen it in this context... */
> +    if (!ret)
> +        ret = hashmap_get_entry(map, &key, hash, &key);
> +
> +    /* ...and finally generate a new mapping if necessary */

I was a bit surprised to see that --seed-anonymized values are stored
in a separate hash map rather than simply being used to (literally)
seed the existing anonymization hash map. I guess there's a good
technical reason for doing it this way, such as the normal
anonymization hash map not yet being in existence at the time the
--seed-anonymized option is processed? (I haven't checked because I'm
too lazy, so it may not be worth spending time answering me.)

> @@ -1188,6 +1230,9 @@ int cmd_fast_export(int argc, const char **argv, const char *prefix)
>         OPT_BOOL(0, "anonymize", &anonymize, N_("anonymize output")),
> +        OPT_CALLBACK_F(0, "seed-anonymized", &anonymized_seeds, N_("from:to"),
> +               N_("convert <from> to <to> in anonymized output"),
> +               PARSE_OPT_NONEG, parse_opt_seed_anonymized),

Would it be worthwhile to add a check somewhere after the
parse_options() invocation and complain if --seed-anonymized was used
without --anonymize?  (Or should --seed-anonymized perhaps imply
--anonymize?)
Eric Sunshine June 23, 2020, 6:11 p.m. UTC | #2
On Tue, Jun 23, 2020 at 11:25 AM Jeff King <peff@peff.net> wrote:
> Let's make it possible to seed the anonymization map. This lets users
> either:
> [...]
> Signed-off-by: Jeff King <peff@peff.net>
> ---
> diff --git a/Documentation/git-fast-export.txt b/Documentation/git-fast-export.txt
> @@ -119,6 +119,11 @@ by keeping the marks the same across runs.
> +--seed-anonymized=<from>[:<to>]::
> +       Convert token `<from>` to `<to>` in the anonymized output. If
> +       `<to>` is omitted, map `<from>` to itself (i.e., do not
> +       anonymize it). See the section on `ANONYMIZING` below.

By the way (possible bikeshedding ahead), "seed anonymous" seems
overly technical. I wonder if a name such as
'--anonymize-to=<from>[:<to>]' might be clearer and easier for people
to understand.

In fact, in an earlier email, I asked whether --seed-anonymized should
imply --anonymize. Thinking further on this, I wonder if we even need
the second option name. It should be possible to overload the existing
--anonymize to handle all functions. For instance:

    '--anonymize' would anonymize everything

    '--anonymize=<from>[:<to>]' would anonymize and map <from> to <to>

So, the example you give in the documentation would become:

    git fast-export --all \
        --anonymize=foo.c:secret.c \
        --anonymize=mybranch >stream

Or is that too cryptic?
Jeff King June 23, 2020, 6:30 p.m. UTC | #3
On Tue, Jun 23, 2020 at 01:16:05PM -0400, Eric Sunshine wrote:

> On Tue, Jun 23, 2020 at 11:25 AM Jeff King <peff@peff.net> wrote:
> > diff --git a/Documentation/git-fast-export.txt b/Documentation/git-fast-export.txt
> > @@ -238,6 +243,25 @@ collapse "User 0", "User 1", etc into "User X"). This produces a much
> > +[...] For example, if you have a bug which reproduces
> > +with `git rev-list mybranch -- foo.c`, you can run:
> > +
> > +---------------------------------------------------
> > +$ git fast-export --anonymize --all \
> > +   --seed-anonymized=foo.c:secret.c \
> > +   --seed-anonymized=mybranch \
> > +   >stream
> > +---------------------------------------------------
> > +
> > +After importing the stream, you can then run `git rev-list mybranch --
> > +secret.c` in the anonymized repository.
> 
> I understand that your intention here is to demonstrate both forms of
> --seed-anonymized, but I'm slightly concerned that people may
> interpret this example as meaning that you are not allowed to
> anonymize the refname when anonymizing a pathname. It might be less
> ambiguous to avoid the "short form" in the example; people who have
> read the description of --seed-anonymized will know that the short
> form can be used without having to see it in an example.

I'm not sure what you'd write, then. You can't mention "mybranch"
anymore if it was anonymized. Are you suggesting to make the example:

  git rev-list -- foo.c

by itself?

> > +Note that paths and refnames are split into tokens at slash boundaries.
> > +The command above would anonymize `subdir/foo.c` as something like
> > +`path123/secret.c`.
> 
> Confusing. This seems to be saying that anonymizing filenames in
> subdirectories is pointless because you can't know how the leading
> directory names will be anonymized. That leaves the reader wondering
> how to deal with the situation. Does it require using
> --seed-anonymized for each path component leading up to the filename?

You can do that, but I think it would be simpler to just find "secret.c"
in the anonymized repo (either in the checkout, or just "git ls-tree
-r").

> Or can --seed-anonymized take an full pathname (leading directory
> components and filename) in one shot?

No, it can't. Suggested wording? That's what I was trying to say with
the above sentence.

> > +    /* First check if it's a token the user configured manually... */
> > +    if (anonymized_seeds.cmpfn)
> > +        ret = hashmap_get_entry(&anonymized_seeds, &key, hash, &key);
> > +    else
> > +        ret = NULL;
> > +
> > +    /* ...otherwise check if we've already seen it in this context... */
> > +    if (!ret)
> > +        ret = hashmap_get_entry(map, &key, hash, &key);
> > +
> > +    /* ...and finally generate a new mapping if necessary */
> 
> I was a bit surprised to see that --seed-anonymized values are stored
> in a separate hash map rather than simply being used to (literally)
> seed the existing anonymization hash map. I guess there's a good
> technical reason for doing it this way, such as the normal
> anonymization hash map not yet being in existence at the time the
> --seed-anonymized option is processed? (I haven't checked because I'm
> too lazy, so it may not be worth spending time answering me.)

The reason is that there isn't one anonymization hash map. There's a
separate one for each generator (so refs become "refs/heads/ref123" and
paths become "path123/path456").

> > @@ -1188,6 +1230,9 @@ int cmd_fast_export(int argc, const char **argv, const char *prefix)
> >         OPT_BOOL(0, "anonymize", &anonymize, N_("anonymize output")),
> > +        OPT_CALLBACK_F(0, "seed-anonymized", &anonymized_seeds, N_("from:to"),
> > +               N_("convert <from> to <to> in anonymized output"),
> > +               PARSE_OPT_NONEG, parse_opt_seed_anonymized),
> 
> Would it be worthwhile to add a check somewhere after the
> parse_options() invocation and complain if --seed-anonymized was used
> without --anonymize?  (Or should --seed-anonymized perhaps imply
> --anonymize?)

I thought about implying, but I have a slight preference to err on the
side of making things less magical. I don't mind triggering a warning or
error, but it's not like anything _bad_ happens if you don't say
--anonymize. It just doesn't do anything, which seems like a perfectly
logical outcome.

-Peff
Jeff King June 23, 2020, 6:35 p.m. UTC | #4
On Tue, Jun 23, 2020 at 02:11:51PM -0400, Eric Sunshine wrote:

> On Tue, Jun 23, 2020 at 11:25 AM Jeff King <peff@peff.net> wrote:
> > Let's make it possible to seed the anonymization map. This lets users
> > either:
> > [...]
> > Signed-off-by: Jeff King <peff@peff.net>
> > ---
> > diff --git a/Documentation/git-fast-export.txt b/Documentation/git-fast-export.txt
> > @@ -119,6 +119,11 @@ by keeping the marks the same across runs.
> > +--seed-anonymized=<from>[:<to>]::
> > +       Convert token `<from>` to `<to>` in the anonymized output. If
> > +       `<to>` is omitted, map `<from>` to itself (i.e., do not
> > +       anonymize it). See the section on `ANONYMIZING` below.
> 
> By the way (possible bikeshedding ahead), "seed anonymous" seems
> overly technical. I wonder if a name such as
> '--anonymize-to=<from>[:<to>]' might be clearer and easier for people
> to understand.

I wrestled with the name, and I agree "seed" is overly technical. And I
came up with many similar variations of "anonymize-to", but they all
seemed ambiguous (e.g., it could be "to" a file that we're storing the
data in).

Perhaps "--anonymize-map" would be less technical?

> In fact, in an earlier email, I asked whether --seed-anonymized should
> imply --anonymize. Thinking further on this, I wonder if we even need
> the second option name. It should be possible to overload the existing
> --anonymize to handle all functions. For instance:
> 
>     '--anonymize' would anonymize everything
> 
>     '--anonymize=<from>[:<to>]' would anonymize and map <from> to <to>
> 
> So, the example you give in the documentation would become:
> 
>     git fast-export --all \
>         --anonymize=foo.c:secret.c \
>         --anonymize=mybranch >stream
> 
> Or is that too cryptic?

Yeah, that was another one I considered, but it both seemed cryptic
(after all, we're saying what _not_ to anonymize), and it squats on the
"anonymize" option. So imagine we had another option later, like
"anonymize blobs and paths, but not refs", that could easily be
"--anonymize=blobs,path" or "--anonymize=!refs". I'd rather not paint
ourselves in a corner.

-Peff
Eric Sunshine June 23, 2020, 8:30 p.m. UTC | #5
On Tue, Jun 23, 2020 at 2:31 PM Jeff King <peff@peff.net> wrote:
> On Tue, Jun 23, 2020 at 01:16:05PM -0400, Eric Sunshine wrote:
> > On Tue, Jun 23, 2020 at 11:25 AM Jeff King <peff@peff.net> wrote:
> > I understand that your intention here is to demonstrate both forms of
> > --seed-anonymized, but I'm slightly concerned that people may
> > interpret this example as meaning that you are not allowed to
> > anonymize the refname when anonymizing a pathname. It might be less
> > ambiguous to avoid the "short form" in the example; people who have
> > read the description of --seed-anonymized will know that the short
> > form can be used without having to see it in an example.
>
> I'm not sure what you'd write, then. You can't mention "mybranch"
> anymore if it was anonymized. Are you suggesting to make the example:
>
>  git rev-list -- foo.c
>
> by itself?

Sorry, I meant to provide an example like this:

    For example, if you have a bug which reproduces with `git rev-list
    sensitive -- secret.c`, you can run:

    $ git fast-export --anonymize --all \
        --seed-anonymized=sensitive:foo \
        --seed-anonymized=secret.c:bar.c \
        >stream

    After importing the stream, you can then run `git rev-list foo --
    bar.c` in the anonymized repository.

> > > +Note that paths and refnames are split into tokens at slash boundaries.
> > > +The command above would anonymize `subdir/foo.c` as something like
> > > +`path123/secret.c`.
> >
> > Confusing. This seems to be saying that anonymizing filenames in
> > subdirectories is pointless because you can't know how the leading
> > directory names will be anonymized. That leaves the reader wondering
> > how to deal with the situation. Does it require using
> > --seed-anonymized for each path component leading up to the filename?
>
> You can do that, but I think it would be simpler to just find "secret.c"
> in the anonymized repo (either in the checkout, or just "git ls-tree
> -r").
>
> > Or can --seed-anonymized take an full pathname (leading directory
> > components and filename) in one shot?
>
> No, it can't. Suggested wording? That's what I was trying to say with
> the above sentence.

Hmm, perhaps your original attempt can be extended slightly to state
it more explicitly?

    Note that paths and refnames are split into tokens at slash
    boundaries. The command above would anonymize `subdir/foo.c` as
    something like `path123/secret.c`; you could then search for
    `secret.c` in the anonymized repository to determine the final
    pathname.

    To make referencing the final pathname simpler, you can seed
    anonymization for each path component; so, if you also anonymize
    `subdir` to `publicdir`, then the final pathname would be
    `publicdir/secret.c`.

This makes me wonder if --seed-anonymized should do its own
tokenization so that --seed-anonymized=subdir/foo:public/bar is
automatically understood as anonymizing "subdir" to "public" _and_
"foo" to "bar". But that potentially gets weird if you say:

    --seed-anonymized=a/b:q/p --seed-anonymized=a/c:y/z

in which case you've given conflicting replacements for "a". (I
suppose it could issue a warning message in that case.)

> > Would it be worthwhile to add a check somewhere after the
> > parse_options() invocation and complain if --seed-anonymized was used
> > without --anonymize? (Or should --seed-anonymized perhaps imply
> > --anonymize?)
>
> I thought about implying, but I have a slight preference to err on the
> side of making things less magical. I don't mind triggering a warning or
> error, but it's not like anything _bad_ happens if you don't say
> --anonymize. It just doesn't do anything, which seems like a perfectly
> logical outcome.

Lack of a warning or error could be kind of bad if the person doesn't
check the fast-export file before sending it out and only discovers
later that:

    git fast-export --seed-anonymized=foo:bar

didn't perform _any_ anonymization at all.
Eric Sunshine June 23, 2020, 8:35 p.m. UTC | #6
On Tue, Jun 23, 2020 at 2:35 PM Jeff King <peff@peff.net> wrote:
> On Tue, Jun 23, 2020 at 02:11:51PM -0400, Eric Sunshine wrote:
> > By the way (possible bikeshedding ahead), "seed anonymous" seems
> > overly technical. I wonder if a name such as
> > '--anonymize-to=<from>[:<to>]' might be clearer and easier for people
> > to understand.
>
> I wrestled with the name, and I agree "seed" is overly technical. And I
> came up with many similar variations of "anonymize-to", but they all
> seemed ambiguous (e.g., it could be "to" a file that we're storing the
> data in).
>
> Perhaps "--anonymize-map" would be less technical?

That's not too bad. It is better than --seed-anonymized. I haven't
come up with any name which improves upon it.

> > In fact, in an earlier email, I asked whether --seed-anonymized should
> > imply --anonymize. Thinking further on this, I wonder if we even need
> > the second option name. It should be possible to overload the existing
> > --anonymize to handle all functions. For instance:
> >
> >   git fast-export --all \
> >     --anonymize=foo.c:secret.c \
> >     --anonymize=mybranch >stream
> >
> > Or is that too cryptic?
>
> Yeah, that was another one I considered, but it both seemed cryptic
> (after all, we're saying what _not_ to anonymize), and it squats on the
> "anonymize" option. So imagine we had another option later, like
> "anonymize blobs and paths, but not refs", that could easily be
> "--anonymize=blobs,path" or "--anonymize=!refs". I'd rather not paint
> ourselves in a corner.

Okay, makes sense.
Jeff King June 24, 2020, 3:47 p.m. UTC | #7
On Tue, Jun 23, 2020 at 04:30:23PM -0400, Eric Sunshine wrote:

> > I'm not sure what you'd write, then. You can't mention "mybranch"
> > anymore if it was anonymized. Are you suggesting to make the example:
> >
> >  git rev-list -- foo.c
> >
> > by itself?
> 
> Sorry, I meant to provide an example like this:
> 
>     For example, if you have a bug which reproduces with `git rev-list
>     sensitive -- secret.c`, you can run:
> 
>     $ git fast-export --anonymize --all \
>         --seed-anonymized=sensitive:foo \
>         --seed-anonymized=secret.c:bar.c \
>         >stream
> 
>     After importing the stream, you can then run `git rev-list foo --
>     bar.c` in the anonymized repository.

Thanks, that makes sense. I took this as-is for my reroll (modulo the
change of option name discussed elsewhere).

> Hmm, perhaps your original attempt can be extended slightly to state
> it more explicitly?
> 
>     Note that paths and refnames are split into tokens at slash
>     boundaries. The command above would anonymize `subdir/foo.c` as
>     something like `path123/secret.c`; you could then search for
>     `secret.c` in the anonymized repository to determine the final
>     pathname.
> 
>     To make referencing the final pathname simpler, you can seed
>     anonymization for each path component; so, if you also anonymize
>     `subdir` to `publicdir`, then the final pathname would be
>     `publicdir/secret.c`.

Thanks, I took this modulo some fixups to match the example above, and
to avoid the use of the word "seed" based on our other discussion.

> This makes me wonder if --seed-anonymized should do its own
> tokenization so that --seed-anonymized=subdir/foo:public/bar is
> automatically understood as anonymizing "subdir" to "public" _and_
> "foo" to "bar". But that potentially gets weird if you say:
> 
>     --seed-anonymized=a/b:q/p --seed-anonymized=a/c:y/z
> 
> in which case you've given conflicting replacements for "a". (I
> suppose it could issue a warning message in that case.)

Right, I think you get into weird corner cases. Another issue is that
not all items are tokenized (e.g., if your author name was foo/bar,
you'd want that replaced as a whole). Probably you could add both the
broken-down and full inputs. Yet another issue is that you can't add a
token with a ":" due to the syntax.

This is an infrequently-enough-used feature that I think it's worth
keeping things simple, even if they're a little less convenient to
invoke.

> Lack of a warning or error could be kind of bad if the person doesn't
> check the fast-export file before sending it out and only discovers
> later that:
> 
>     git fast-export --seed-anonymized=foo:bar
> 
> didn't perform _any_ anonymization at all.

Good point. I'd hope people would glance at the output before sending it
out, but given that it's a potential safety issue, it probably is worth
detecting this case. I'll add it to my re-roll.

-Peff
Jeff King June 24, 2020, 3:48 p.m. UTC | #8
On Tue, Jun 23, 2020 at 04:35:37PM -0400, Eric Sunshine wrote:

> > I wrestled with the name, and I agree "seed" is overly technical. And I
> > came up with many similar variations of "anonymize-to", but they all
> > seemed ambiguous (e.g., it could be "to" a file that we're storing the
> > data in).
> >
> > Perhaps "--anonymize-map" would be less technical?
> 
> That's not too bad. It is better than --seed-anonymized. I haven't
> come up with any name which improves upon it.

I went with that in my reroll, and avoided using the word "seed" at all
in the documentation (I did keep the name "anonymized_seeds" as the
internal variable name for the hashmap, since just calling it "map"
there is ambiguous with all of the other maps).

-Peff
diff mbox series

Patch

diff --git a/Documentation/git-fast-export.txt b/Documentation/git-fast-export.txt
index e8950de3ba..2d7b62e835 100644
--- a/Documentation/git-fast-export.txt
+++ b/Documentation/git-fast-export.txt
@@ -119,6 +119,11 @@  by keeping the marks the same across runs.
 	the shape of the history and stored tree.  See the section on
 	`ANONYMIZING` below.
 
+--seed-anonymized=<from>[:<to>]::
+	Convert token `<from>` to `<to>` in the anonymized output. If
+	`<to>` is omitted, map `<from>` to itself (i.e., do not
+	anonymize it). See the section on `ANONYMIZING` below.
+
 --reference-excluded-parents::
 	By default, running a command such as `git fast-export
 	master~5..master` will not include the commit master{tilde}5
@@ -238,6 +243,25 @@  collapse "User 0", "User 1", etc into "User X"). This produces a much
 smaller output, and it is usually easy to quickly confirm that there is
 no private data in the stream.
 
+Reproducing some bugs may require referencing particular commits or
+paths, which becomes challenging after refnames and paths have been
+anonymized. You can ask for a particular token to be left as-is or
+mapped to a new value. For example, if you have a bug which reproduces
+with `git rev-list mybranch -- foo.c`, you can run:
+
+---------------------------------------------------
+$ git fast-export --anonymize --all \
+      --seed-anonymized=foo.c:secret.c \
+      --seed-anonymized=mybranch \
+      >stream
+---------------------------------------------------
+
+After importing the stream, you can then run `git rev-list mybranch --
+secret.c` in the anonymized repository.
+
+Note that paths and refnames are split into tokens at slash boundaries.
+The command above would anonymize `subdir/foo.c` as something like
+`path123/secret.c`.
 
 LIMITATIONS
 -----------
diff --git a/builtin/fast-export.c b/builtin/fast-export.c
index 1cbca5b4b4..ef82497bbf 100644
--- a/builtin/fast-export.c
+++ b/builtin/fast-export.c
@@ -45,6 +45,7 @@  static struct string_list extra_refs = STRING_LIST_INIT_NODUP;
 static struct string_list tag_refs = STRING_LIST_INIT_NODUP;
 static struct refspec refspecs = REFSPEC_INIT_FETCH;
 static int anonymize;
+static struct hashmap anonymized_seeds;
 static struct revision_sources revision_sources;
 
 static int parse_opt_signed_tag_mode(const struct option *opt,
@@ -168,8 +169,18 @@  static const char *anonymize_str(struct hashmap *map,
 	hashmap_entry_init(&key.hash, memhash(orig, len));
 	key.orig = orig;
 	key.orig_len = len;
-	ret = hashmap_get_entry(map, &key, hash, &key);
 
+	/* First check if it's a token the user configured manually... */
+	if (anonymized_seeds.cmpfn)
+		ret = hashmap_get_entry(&anonymized_seeds, &key, hash, &key);
+	else
+		ret = NULL;
+
+	/* ...otherwise check if we've already seen it in this context... */
+	if (!ret)
+		ret = hashmap_get_entry(map, &key, hash, &key);
+
+	/* ...and finally generate a new mapping if necessary */
 	if (!ret) {
 		FLEX_ALLOC_MEM(ret, orig, orig, len);
 		hashmap_entry_init(&ret->hash, key.hash.hash);
@@ -1147,6 +1158,37 @@  static void handle_deletes(void)
 	}
 }
 
+static char *anonymize_seed(void *data)
+{
+	return xstrdup(data);
+}
+
+static int parse_opt_seed_anonymized(const struct option *opt,
+				     const char *arg, int unset)
+{
+	struct hashmap *map = opt->value;
+	const char *delim, *value;
+	size_t keylen;
+
+	BUG_ON_OPT_NEG(unset);
+
+	delim = strchr(arg, ':');
+	if (delim) {
+		keylen = delim - arg;
+		value = delim + 1;
+	} else {
+		keylen = strlen(arg);
+		value = arg;
+	}
+
+	if (!keylen || !*value)
+		return error(_("--seed-anonymized token cannot be empty"));
+
+	anonymize_str(map, anonymize_seed, arg, keylen, (void *)value);
+
+	return 0;
+}
+
 int cmd_fast_export(int argc, const char **argv, const char *prefix)
 {
 	struct rev_info revs;
@@ -1188,6 +1230,9 @@  int cmd_fast_export(int argc, const char **argv, const char *prefix)
 		OPT_STRING_LIST(0, "refspec", &refspecs_list, N_("refspec"),
 			     N_("Apply refspec to exported refs")),
 		OPT_BOOL(0, "anonymize", &anonymize, N_("anonymize output")),
+		OPT_CALLBACK_F(0, "seed-anonymized", &anonymized_seeds, N_("from:to"),
+			       N_("convert <from> to <to> in anonymized output"),
+			       PARSE_OPT_NONEG, parse_opt_seed_anonymized),
 		OPT_BOOL(0, "reference-excluded-parents",
 			 &reference_excluded_commits, N_("Reference parents which are not in fast-export stream by object id")),
 		OPT_BOOL(0, "show-original-ids", &show_original_ids,
diff --git a/t/t9351-fast-export-anonymize.sh b/t/t9351-fast-export-anonymize.sh
index dc5d75cd19..d84eec9bab 100755
--- a/t/t9351-fast-export-anonymize.sh
+++ b/t/t9351-fast-export-anonymize.sh
@@ -6,6 +6,7 @@  test_description='basic tests for fast-export --anonymize'
 test_expect_success 'setup simple repo' '
 	test_commit base &&
 	test_commit foo &&
+	test_commit retain-me &&
 	git checkout -b other HEAD^ &&
 	mkdir subdir &&
 	test_commit subdir/bar &&
@@ -18,7 +19,10 @@  test_expect_success 'setup simple repo' '
 '
 
 test_expect_success 'export anonymized stream' '
-	git fast-export --anonymize --all >stream
+	git fast-export --anonymize --all \
+		--seed-anonymized=retain-me \
+		--seed-anonymized=xyzzy:custom-name \
+		>stream
 '
 
 # this also covers commit messages
@@ -30,6 +34,11 @@  test_expect_success 'stream omits path names' '
 	! grep xyzzy stream
 '
 
+test_expect_success 'stream contains user-specified names' '
+	grep retain-me stream &&
+	grep custom-name stream
+'
+
 test_expect_success 'stream omits gitlink oids' '
 	# avoid relying on the whole oid to remain hash-agnostic; this is
 	# plenty to be unique within our test case