[Libguestfs] [nbdkit PATCH v2 8/8] exportname: New filter

Eric Blake
2020-08-27T02:16:52Z

Add a new filter to make it easier to add exports to a plugin that
does advertise them, to avoid advertising where a plugin's list might
be an information leak, and to alter which export name is used in
place of "".

I would love to be able to have a strict mode enforcing that .open is
called only with an export name that the plugin was willing to
advertise, but for that, we'll need a way to call the plugin's
.list_exports from within the context of .open (since we can't
guarantee the guest will call NBD_OPT_LIST).  So for now, strict mode
requires redundant exportname= command line parameters.  It may also
be worth adding a way to pass exportname-file=/path/to/file, which
then gets parsed the same was as the sh plugin (right now with NAMES,
INTERLEAVED, or NAMES+DESCRIPTIONS), to avoid having to do so many
export names directly on the command line.

Signed-off-by: Eric Blake <eblake@redhat.com>
---
 .../exportname/nbdkit-exportname-filter.pod   | 154 ++++++++
 filters/ext2/nbdkit-ext2-filter.pod           |   5 +
 plugins/file/nbdkit-file-plugin.pod           |   9 +-
 configure.ac                                  |   2 +
 filters/exportname/Makefile.am                |  67 ++++
 tests/Makefile.am                             |   4 +
 filters/exportname/exportname.c               | 342 ++++++++++++++++++
 tests/test-exportname.sh                      | 184 ++++++++++
 TODO                                          |   9 +
 9 files changed, 773 insertions(+), 3 deletions(-)
 create mode 100644 filters/exportname/nbdkit-exportname-filter.pod
 create mode 100644 filters/exportname/Makefile.am
 create mode 100644 filters/exportname/exportname.c
 create mode 100755 tests/test-exportname.sh

diff --git a/filters/exportname/nbdkit-exportname-filter.pod
b/filters/exportname/nbdkit-exportname-filter.pod
new file mode 100644
index 00000000..4de56dee
--- /dev/null
+++ b/filters/exportname/nbdkit-exportname-filter.pod
@@ -0,0 +1,154 @@
+=head1 NAME
+
+nbdkit-exportname-filter - adjust export names between client and plugin
+
+=head1 SYNOPSIS
+
+ nbdkit --filter=exportname plugin [default-export=NAME]
+  [exportname-list=MODE] [exportname-strict=true] [exportname=NAME]...
+  [exportdesc=DESC]
+
+=head1 DESCRIPTION
+
+Some plugins (such as C<nbdkit-file-plugin(1)> and filters (such as
+C<nbdkit-ext2-filter(1)> are able to serve different content based on
+the export name requested by the client.  The NBD protocol allows a
+server to advertise the set of export names it is serving.  However,
+the list advertised (or absent) from the plugin may not always match
+what you want an actual client to see.  This filter can be used to
+alter the advertised list, as well as configuring which export should
+be treated as the default when the client requests the empty string
+(C<"">) as an export name.
+
+=head1 PARAMETERS
+
+=over 4
+
+=item B<default-export=>NAME
+
+When the client requests the default export name (C<"">),
request the
+export C<NAME> from the underlying plugin instead of relying on the
+plugin's choice of default export.  Setting NAME to the empty string
+has the same effect as omitting this parameter.
+
+=item B<exportname-list=keep>
+
+=item B<exportname-list=error>
+
+=item B<exportname-list=empty>
+
+=item B<exportname-list=defaultonly>
+
+=item B<exportname-list=explicit>
+
+This parameter determines which exports are advertised to a guest that
+requests a listing via C<NBD_OPT_LIST>.  The default mode is
C<keep>
+to advertise whatever the underlying plugin reports.  Mode C<error>
+causes clients to see an error rather than an export list.  Mode
+C<empty> returns an empty list.  Mode C<defaultonly> returns a list
+that contains only the canonical name of the default export.  Mode
+C<explicit> returns only the exports set by C<exportname=>.  Note
that
+the list of advertised exports need not reflect reality: an advertised
+name may be rejected, or a client may connect to an export name that
+was not advertised, but learned through other means.
+
+=item B<exportname-strict=false>
+
+=item B<exportname-strict=true>
+
+Normally, a client can pass whatever export name it wants, regardless
+of whether that name is advertised.  But setting this parameter to
+true will cause the connection to fail if a client requests an export
+name that was not included via an B<exportname=> parameter.  At this
+time, it is not possible to restrict a client to exports advertised by
+the plugin without repeating that list via B<exportname>; this
+technical limitation may be lifted in the future.
+
+=item B<exportname=>NAME
+
+This parameter adds C<NAME> to the list of advertised exports; it may
+be set multiple times.
+
+=item B<exportdesc=keep>
+
+=item B<exportdesc=none>
+
+=item B<exportdesc=fixed:>STRING
+
+=item B<exportdesc=script:>SCRIPT
+
+The C<exportdesc> parameter controls what optional descriptions are
+sent alongside an export name.  If set to C<keep> (the default),
+descriptions are determined by the plugin.  If set to C<none>,
+descriptions from the plugin are ignored (useful if you are worried
+about a potential information leak).  If set to C<fixed:STRING>, the
+same fixed string description is offered for every export.  If set to
+C<script:SCRIPT>, this filter executes script with C<$name> set to
the
+export to be described, and uses the output of that command as the
+description.
+
+=back
+
+=head1 EXAMPLES
+
+Suppose that the directory /path/to/dir contains permanent files named
+file1, file2, and file3.  The following commands show various ways to
+alter the use of export names while serving that directory:
+
+Allow a client requesting C<""> to get the contents of file2,
rather
+than an error:
+
+ nbdkit --filter=exportname file dir=/path/to/dir default-export=file2
+
+Do not advertise any exports; a client must know in advance what
+export names to try:
+
+ nbdkit --filter=exportname file dir=/path/to/dir exportname-list=empty
+
+Allow clients to connect to file1 and file3, but not file2:
+
+ nbdkit --filter=exportname file dir=/path/to/dir \
+   exportname-list=explicit exportname-strict=true \
+   exportname=file1 exportname=file3
+
+Offer C<ls(3)> long descriptions alongside each export name:
+
+ nbdkit --filter=exportname file dir=/path/to/dir \
+   exportdesc=script:'ls -l /path/to/dir/"$name"'
+
+=head1 FILES
+
+=over 4
+
+=item F<$filterdir/nbdkit-exportname-filter.so>
+
+The filter.
+
+Use C<nbdkit --dump-config> to find the location of C<$filterdir>.
+
+=back
+
+=head1 VERSION
+
+C<nbdkit-exportname-filter> first appeared in nbdkit 1.22.
+
+=head1 SEE ALSO
+
+L<nbdkit(1)>,
+L<nbdkit-filter(3)>,
+L<nbdkit-ext2-filter(1)>,
+L<nbdkit-extentlist-filter(1)>,
+L<nbdkit-fua-filter(1)>,
+L<nbdkit-nocache-filter(1)>,
+L<nbdkit-noparallel-filter(1)>,
+L<nbdkit-nozero-filter(1)>,
+L<nbdkit-file-plugin(1)>,
+L<nbdkit-info-plugin(1)>.
+
+=head1 AUTHORS
+
+Eric Blake
+
+=head1 COPYRIGHT
+
+Copyright (C) 2020 Red Hat Inc.
diff --git a/filters/ext2/nbdkit-ext2-filter.pod
b/filters/ext2/nbdkit-ext2-filter.pod
index 78d27f8f..1aef9c2e 100644
--- a/filters/ext2/nbdkit-ext2-filter.pod
+++ b/filters/ext2/nbdkit-ext2-filter.pod
@@ -65,6 +65,10 @@ exportname passed by the client.  Note that this mode allows
the
 client to deduce which files exist within the disk image, which may be
 a security risk in some situations.

+At present, when using this mode, the server does not advertise any
+particular exports; however, you may use
+L<nbdkit-exportname-filter(1)> to perform that task.
+
 =back

 =head1 FILES
@@ -89,6 +93,7 @@ and removed in nbdkit 1.22.

 L<nbdkit(1)>,
 L<nbdkit-plugin(3)>,
+L<nbdkit-exportname-filter(1)>,
 L<nbdkit-partition-filter(1)>,
 L<nbdkit-guestfs-plugin(1)>,
 L<http://e2fsprogs.sourceforge.net/>,
diff --git a/plugins/file/nbdkit-file-plugin.pod
b/plugins/file/nbdkit-file-plugin.pod
index 3844d75f..f6f31a76 100644
--- a/plugins/file/nbdkit-file-plugin.pod
+++ b/plugins/file/nbdkit-file-plugin.pod
@@ -65,10 +65,12 @@ directory named C<DIRNAME>, including those found by
following
 symbolic links.  Other special files in the directory (such as
 subdirectories, fifos, or Unix sockets) are ignored.  When this mode
 is used, the file to be served is chosen by the export name passed by
-the client.  For now, the client must already know what files are
-in the directory, although there are plans to expose them via
+the client.  For now, the client must already know what files are in
+the directory, although there are plans to expose them via
 NBD_OPT_LIST; likewise, a client that requests the default export
-(C<"">) will be rejected.  For security, when using directory
mode,
+(C<"">) will be rejected.  However, you can use
+L<nbdkit-exportname-filter(1)> to adjust what export names the client
+sees or uses as a default.  For security, when using directory mode,
 this plugin will not accept export names containing slash (C</>).

 =back
@@ -183,6 +185,7 @@ L<nbdkit-plugin(3)>,
 L<nbdkit-split-plugin(1)>,
 L<nbdkit-partitioning-plugin(1)>,
 L<nbdkit-tmpdisk-plugin(1)>,
+L<nbdkit-exportname-filter(1)>,
 L<nbdkit-fua-filter(1)>,
 L<nbdkit-noextents-filter(1)>.

diff --git a/configure.ac b/configure.ac
index c0142b62..a11a3c59 100644
--- a/configure.ac
+++ b/configure.ac
@@ -104,6 +104,7 @@ filters="\
         delay \
         error \
         exitlast \
+	exportname \
         ext2 \
         extentlist \
         fua \
@@ -1161,6 +1162,7 @@ AC_CONFIG_FILES([Makefile
                  filters/delay/Makefile
                  filters/error/Makefile
                  filters/exitlast/Makefile
+                 filters/exportname/Makefile
                  filters/ext2/Makefile
                  filters/extentlist/Makefile
                  filters/fua/Makefile
diff --git a/filters/exportname/Makefile.am b/filters/exportname/Makefile.am
new file mode 100644
index 00000000..1bf54518
--- /dev/null
+++ b/filters/exportname/Makefile.am
@@ -0,0 +1,67 @@
+# nbdkit
+# Copyright (C) 2018-2020 Red Hat Inc.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# * Neither the name of Red Hat nor the names of its contributors may be
+# used to endorse or promote products derived from this software without
+# specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY RED HAT AND CONTRIBUTORS ''AS
IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL RED HAT OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
+# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
+# OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+# SUCH DAMAGE.
+
+include $(top_srcdir)/common-rules.mk
+
+EXTRA_DIST = nbdkit-exportname-filter.pod
+
+filter_LTLIBRARIES = nbdkit-exportname-filter.la
+
+nbdkit_exportname_filter_la_SOURCES = \
+	exportname.c \
+	$(top_srcdir)/include/nbdkit-filter.h \
+	$(NULL)
+
+nbdkit_exportname_filter_la_CPPFLAGS = \
+	-I$(top_srcdir)/include \
+	-I$(top_srcdir)/common/include \
+	-I$(top_srcdir)/common/utils \
+	$(NULL)
+nbdkit_exportname_filter_la_CFLAGS = $(WARNINGS_CFLAGS)
+nbdkit_exportname_filter_la_LDFLAGS = \
+	-module -avoid-version -shared $(SHARED_LDFLAGS) \
+	-Wl,--version-script=$(top_srcdir)/filters/filters.syms \
+	$(NULL)
+nbdkit_exportname_filter_la_LIBADD = \
+	$(top_builddir)/common/utils/libutils.la \
+	$(NULL)
+
+if HAVE_POD
+
+man_MANS = nbdkit-exportname-filter.1
+CLEANFILES += $(man_MANS)
+
+nbdkit-exportname-filter.1: nbdkit-exportname-filter.pod
+	$(PODWRAPPER) --section=1 --man $@ \
+	    --html $(top_builddir)/html/$@.html \
+	    $<
+
+endif HAVE_POD
diff --git a/tests/Makefile.am b/tests/Makefile.am
index 29c4811b..a316decb 100644
--- a/tests/Makefile.am
+++ b/tests/Makefile.am
@@ -1321,6 +1321,10 @@ EXTRA_DIST += \
 TESTS += test-exitlast.sh
 EXTRA_DIST += test-exitlast.sh

+# exportname filter test.
+TESTS += test-exportname.sh
+EXTRA_DIST += test-exportname.sh
+
 # ext2 filter test.
 if HAVE_MKE2FS_WITH_D
 if HAVE_EXT2
diff --git a/filters/exportname/exportname.c b/filters/exportname/exportname.c
new file mode 100644
index 00000000..910030ec
--- /dev/null
+++ b/filters/exportname/exportname.c
@@ -0,0 +1,342 @@
+/* nbdkit
+ * Copyright (C) 2020 Red Hat Inc.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of Red Hat nor the names of its contributors may be
+ * used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY RED HAT AND CONTRIBUTORS ''AS
IS'' AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+ * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL RED HAT OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
+ * USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
+ * OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ * SUCH DAMAGE.
+ */
+
+#include <config.h>
+
+#include <stdio.h>
+#include <stdbool.h>
+#include <string.h>
+
+#include <nbdkit-filter.h>
+
+#include "cleanup.h"
+#include "utils.h"
+
+static const char *default_export;
+static enum {
+  LIST_KEEP,
+  LIST_ERROR,
+  LIST_EMPTY,
+  LIST_DEFAULT,
+  LIST_EXPLICIT,
+} list;
+static bool strict;
+static enum {
+  DESC_KEEP,
+  DESC_NONE,
+  DESC_FIXED,
+  DESC_SCRIPT,
+} desc_mode;
+static const char *desc;
+struct nbdkit_exports *exports;
+
+static void
+exportname_load (void)
+{
+  exports = nbdkit_exports_new ();
+  if (!exports) {
+    nbdkit_error ("malloc: %m");
+    exit (EXIT_FAILURE);
+  }
+}
+
+static void
+exportname_unload (void)
+{
+  nbdkit_exports_free (exports);
+}
+
+/* Called for each key=value passed on the command line. */
+static int
+exportname_config (nbdkit_next_config *next, void *nxdata,
+                   const char *key, const char *value)
+{
+  int r;
+
+  if (strcmp (key, "default-export") == 0 ||
+      strcmp (key, "default_export") == 0) {
+    default_export = value;
+    return 0;
+  }
+  if (strcmp (key, "exportname-list") == 0 ||
+      strcmp (key, "exportname_list") == 0) {
+    if (strcmp (value, "keep") == 0)
+      list = LIST_KEEP;
+    else if (strcmp (value, "error") == 0)
+      list = LIST_ERROR;
+    else if (strcmp (value, "empty") == 0)
+      list = LIST_EMPTY;
+    else if (strcmp (value, "defaultonly") == 0 ||
+             strcmp (value, "default-only") == 0)
+      list = LIST_DEFAULT;
+    else if (strcmp (value, "explicit") == 0)
+      list = LIST_EXPLICIT;
+    else {
+      nbdkit_error ("unrecognized exportname-list mode: %s", value);
+      return -1;
+    }
+    return 0;
+  }
+  if (strcmp (key, "exportname-strict") == 0 ||
+      strcmp (key, "exportname_strict") == 0) {
+    r = nbdkit_parse_bool (value);
+    if (r == -1)
+      return -1;
+    strict = r;
+    return 0;
+  }
+  if (strcmp (key, "exportname") == 0)
+    return nbdkit_add_export (exports, value, NULL);
+  if (strcmp (key, "exportdesc") == 0) {
+    if (strcmp (value, "keep") == 0)
+      desc_mode = DESC_KEEP;
+    else if (strcmp (value, "none") == 0) {
+      desc_mode = DESC_NONE;
+      desc = NULL;
+    }
+    else if (strncmp (value, "fixed:", 6) == 0) {
+      desc_mode = DESC_FIXED;
+      desc = value + 6;
+    }
+    else if (strncmp (value, "script:", 7) == 0) {
+      desc_mode = DESC_SCRIPT;
+      desc = value + 7;
+    }
+    else {
+      nbdkit_error ("unrecognized exportdesc mode: %s", value);
+      return -1;
+    }
+    return 0;
+  }
+  return next (nxdata, key, value);
+}
+
+#define exportname_config_help \
+  "default-export=<NAME>     Canonical name for the \"\"
default export.\n" \
+  "exportname-list=<MODE>    Which exports to advertise: keep
(default), error,\n" \
+  "                          empty, defaultonly, explicit.\n" \
+  "exportname-strict=<BOOL>  Limit clients to explicit exports
(default false).\n" \
+  "exportname=<NAME>         Add an explicit export name, may be
repeated.\n" \
+  "exportdesc=<MODE>         Set descriptions according to mode:
keep (default),\n" \
+  "                          none, fixed:STRING, script:SCRIPT.\n" \
+
+static const char *
+get_desc (const char *name, const char *def)
+{
+  FILE *fp;
+  CLEANUP_FREE char *cmd = NULL;
+  size_t cmdlen = 0;
+  char buf[4096]; /* Maximum NBD string; we truncate any longer response */
+  size_t r;
+
+  switch (desc_mode) {
+  case DESC_KEEP:
+    return def;
+  case DESC_NONE:
+  case DESC_FIXED:
+    return desc;
+  case DESC_SCRIPT:
+    break;
+  default:
+    abort ();
+  }
+
+  /* Construct the command. */
+  fp = open_memstream (&cmd, &cmdlen);
+  if (fp == NULL) {
+    nbdkit_debug ("open_memstream: %m");
+    return NULL;
+  }
+  fprintf (fp, "export name; name=");
+  shell_quote (name, fp);
+  fprintf (fp, "\n%s\n", desc);
+  if (fclose (fp) == EOF) {
+    nbdkit_debug ("memstream failed: %m");
+    return NULL;
+  }
+  nbdkit_debug ("%s", cmd);
+  fp = popen (cmd, "r");
+  if (fp == NULL) {
+    nbdkit_debug ("popen: %m");
+    return NULL;
+  }
+
+  /* Now read the description */
+  r = fread (buf, 1, sizeof buf, fp);
+  if (r == 0 && ferror (fp)) {
+    nbdkit_debug ("fread: %m");
+    pclose (fp);
+    return NULL;
+  }
+  pclose (fp);
+  if (r && buf[r-1] == '\n')
+    r--;
+  return nbdkit_strndup_intern (buf, r);
+}
+
+static int
+exportname_list_exports (nbdkit_next_list_exports *next, void *nxdata,
+                         int readonly, int is_tls,
+                         struct nbdkit_exports *exps)
+{
+  size_t i;
+  struct nbdkit_exports *source;
+  CLEANUP_EXPORTS_FREE struct nbdkit_exports *exps2 = NULL;
+
+  switch (list) {
+  case LIST_KEEP:
+    source = exps2 = nbdkit_exports_new ();
+    if (exps2 == NULL)
+      return -1;
+    if (next (nxdata, readonly, exps2) == -1)
+      return -1;
+    break;
+  case LIST_ERROR:
+    nbdkit_error ("export list restricted by policy");
+    return -1;
+  case LIST_EMPTY:
+    return 0;
+  case LIST_DEFAULT:
+    return nbdkit_add_default_export (exps);
+  case LIST_EXPLICIT:
+    source = exports;
+    break;
+  default:
+    abort ();
+  }
+
+  for (i = 0; i < nbdkit_exports_count (source); i++) {
+    struct nbdkit_export e = nbdkit_get_export (source, i);
+
+    if (nbdkit_add_export (exps, e.name,
+                           get_desc (e.name, e.description)) == -1)
+      return -1;
+  }
+  return 0;
+}
+
+static const char *
+exportname_default_export (nbdkit_next_default_export *next, void *nxdata,
+                           int readonly, int is_tls)
+{
+  size_t i;
+
+  /* If we are strict, do not allow connection unless "" was
advertised. */
+  if (strict) {
+    for (i = 0; i < nbdkit_exports_count (exports); i++) {
+      if (nbdkit_get_export (exports, i).name[0] == '\0')
+        return default_export ?: "";
+    }
+    return NULL;
+  }
+
+  if (default_export)
+    return default_export;
+  return next (nxdata, readonly);
+}
+
+struct handle {
+  const char *name;
+};
+
+static void *
+exportname_open (nbdkit_next_open *next, void *nxdata,
+                 int readonly, const char *exportname, int is_tls)
+{
+  size_t i;
+  struct handle *h;
+
+  if (strict) {
+    for (i = 0; i < nbdkit_exports_count (exports); i++) {
+      if (strcmp (nbdkit_get_export (exports, i).name, exportname) == 0)
+        break;
+    }
+    if (i == nbdkit_exports_count (exports)) {
+      nbdkit_error ("export '%s not found", exportname);
+      errno = ENOENT;
+      return NULL;
+    }
+  }
+
+  h = malloc (sizeof *h);
+  if (h == NULL) {
+    nbdkit_error ("malloc: %m");
+    return NULL;
+  }
+
+  h->name = nbdkit_strdup_intern (exportname);
+  if (h->name == NULL) {
+    free (h);
+    return NULL;
+  }
+  if (next (nxdata, readonly, exportname) == -1) {
+    free (h);
+    return NULL;
+  }
+
+  return h;
+}
+
+static void
+exportname_close (void *handle)
+{
+  free (handle);
+}
+
+static const char *
+exportname_export_description (struct nbdkit_next_ops *next_ops, void *nxdata,
+                               void *handle)
+{
+  struct handle *h = handle;
+  const char *def = NULL;
+
+  if (desc_mode == DESC_KEEP)
+    def = next_ops->export_description (nxdata);
+
+  return get_desc (h->name, def);
+}
+
+static struct nbdkit_filter filter = {
+  .name               = "exportname",
+  .longname           = "nbdkit exportname filter",
+  .load               = exportname_load,
+  .unload             = exportname_unload,
+  .config             = exportname_config,
+  .config_help        = exportname_config_help,
+  .list_exports       = exportname_list_exports,
+  .default_export     = exportname_default_export,
+  .open               = exportname_open,
+  .close              = exportname_close,
+  .export_description = exportname_export_description,
+};
+
+NBDKIT_REGISTER_FILTER(filter)
diff --git a/tests/test-exportname.sh b/tests/test-exportname.sh
new file mode 100755
index 00000000..2d30df4e
--- /dev/null
+++ b/tests/test-exportname.sh
@@ -0,0 +1,184 @@
+#!/usr/bin/env bash
+# nbdkit
+# Copyright (C) 2019-2020 Red Hat Inc.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# * Neither the name of Red Hat nor the names of its contributors may be
+# used to endorse or promote products derived from this software without
+# specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY RED HAT AND CONTRIBUTORS ''AS
IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL RED HAT OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
+# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
+# OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+# SUCH DAMAGE.
+
+source ./functions.sh
+set -e
+set -x
+
+requires_plugin sh
+requires nbdinfo --version
+requires nbdsh -c 'print (h.set_full_info)'
+requires jq --version
+
+files="exportname.out exportname.sh"
+rm -rf $files
+cleanup_fn rm -rf $files
+
+query='[ [.exports[]] | sort_by(."export-name")[] |
+  [."export-name", .description, ."export-size"] ]'
+fail=0
+
+cat >exportname.sh <<\EOF
+case $1 in
+  list_exports)
+    echo INTERLEAVED
+    echo a; echo x
+    echo b; echo y
+    echo c; echo z
+    ;;
+  default_export) echo a ;;
+  open) echo "$3" ;;
+  export_description | get_size)
+    case $2 in
+      a) echo 1 ;;
+      b) echo 2 ;;
+      c) echo 3 ;;
+      *) exit 1 ;
+    esac ;;
+  *) exit 2 ;;
+esac
+EOF
+chmod +x exportname.sh
+
+# Establish a baseline
+nbdkit -U - sh exportname.sh \
+       --run 'nbdinfo --json --list "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" = \
+
'[["a","x",1],["b","y",2],["c","z",3]]'
+
+# Set the default export
+nbdkit -U - --filter=exportname sh exportname.sh default-export= \
+       --run 'nbdinfo --no-content --json "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" =
'[["","1",1]]'
+
+nbdkit -U - --filter=exportname sh exportname.sh default-export=b \
+       --run 'nbdinfo --no-content --json "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" =
'[["b","2",2]]'
+
+# Test export list policies
+nbdkit -U - --filter=exportname sh exportname.sh exportname-list=keep \
+       --run 'nbdinfo --json --list "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" = \
+
'[["a","x",1],["b","y",2],["c","z",3]]'
+
+nbdkit -U - --filter=exportname sh exportname.sh exportname-list=error \
+       --run 'nbdinfo --json --list "$uri"' >
exportname.out && fail=1 || :
+test ! -s exportname.out
+
+nbdkit -U - --filter=exportname sh exportname.sh exportname-list=empty \
+       --run 'nbdinfo --json --list "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" = '[]'
+
+nbdkit -U - --filter=exportname sh exportname.sh default-export=b \
+       exportname-list=defaultonly exportname=a exportname=b \
+       --run 'nbdinfo --json --list "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" =
'[["b",null,2]]'
+
+nbdkit -U - --filter=exportname sh exportname.sh default-export=b \
+       exportname-list=defaultonly exportname=a exportname=b \
+       --run 'nbdinfo --json --list "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" =
'[["b",null,2]]'
+
+nbdkit -U - --filter=exportname sh exportname.sh \
+       exportname-list=explicit exportname=b exportname=a \
+       --run 'nbdinfo --json --list "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" =
'[["a",null,1],["b",null,2]]'
+
+# Test description modes with lists
+nbdkit -U - --filter=exportname sh exportname.sh exportdesc=keep \
+       --run 'nbdinfo --json --list "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" = \
+
'[["a","x",1],["b","y",2],["c","z",3]]'
+
+nbdkit -U - --filter=exportname sh exportname.sh exportdesc=none \
+       --run 'nbdinfo --json --list "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" = \
+
'[["a",null,1],["b",null,2],["c",null,3]]'
+
+nbdkit -U - --filter=exportname sh exportname.sh exportdesc=fixed:hi \
+       --run 'nbdinfo --json --list "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" = \
+
'[["a","hi",1],["b","hi",2],["c","hi",3]]'
+
+nbdkit -U - --filter=exportname sh exportname.sh \
+       exportdesc=script:'echo $name$name' \
+       --run 'nbdinfo --json --list "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" = \
+
'[["a","aa",1],["b","bb",2],["c","cc",3]]'
+
+# Test description modes with connections
+nbdkit -U - -e c --filter=exportname sh exportname.sh exportdesc=fixed:hi \
+       --run 'nbdinfo --no-content --json "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" =
'[["c","hi",3]]'
+
+nbdkit -U - -e c --filter=exportname sh exportname.sh \
+       exportdesc=script:'echo $name$name' \
+       --run 'nbdinfo --no-content --json "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" =
'[["c","cc",3]]'
+
+# Test strict mode
+nbdkit -U - --filter=exportname sh exportname.sh exportname-strict=true \
+       --run 'nbdinfo --no-content --json "$uri"' >
exportname.out && fail=1
+test ! -s exportname.out
+
+nbdkit -U - --filter=exportname sh exportname.sh exportname-strict=true \
+       exportname=a exportname=b exportname=c \
+       --run 'nbdinfo --no-content --json "$uri"' >
exportname.out && fail=1
+test ! -s exportname.out
+
+nbdkit -U - --filter=exportname sh exportname.sh exportname-strict=true \
+       exportname=a exportname=b exportname= default-export=a\
+       --run 'nbdinfo --no-content --json "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" =
'[["a","1",1]]'
+
+nbdkit -U - -e a --filter=exportname sh exportname.sh exportname-strict=true \
+       exportname=a exportname=b exportname=c \
+       --run 'nbdinfo --no-content --json "$uri"' >
exportname.out
+cat exportname.out
+test "$(jq -c "$query" exportname.out)" =
'[["a","1",1]]'
+
+exit $fail
diff --git a/TODO b/TODO
index ff902c7d..2032b27b 100644
--- a/TODO
+++ b/TODO
@@ -252,6 +252,15 @@ nbdkit-tls-fallback-filter:
 * Adjust handling of .list_exports to mask what the plugin advertises
   when tls is not yet active.

+nbdkit-exportname-fitler:
+
+* find a way to call the plugin's .list_exports during .open, so that
+  we can enforce exportname-strict=true without command line redundancy
+
+* add a mode for passing in a file containing exportnames in the same
+  manner accepted by the sh/eval plugins, rather than one name (and no
+  description) per config parameter
+
 Filters for security
 --------------------

--
2.28.0