[Libguestfs] [nbdkit PATCH v2 7/8] api: Add .export_description

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

I'm about to add an 'exportname' filter, and in the process, I
noticed
a few shortcomings in our API.  Time to fix those before the 1.22
release locks our API in stone.  Having .default_export makes it easy
to answer NBD_INFO_NAME in response to a client request during
NBD_OPT_GO, but answering NBD_INFO_DESCRIPTION is awkward - there's no
guarantee the export name was given with a description in
.list_exports.  Note, however, that while we map "" into a canonical
name prior to .open (as the plugin is easier to write if it can just
assume the client already passed in the canonical name), we don't need
a description until after .open, alongside .get_size.

The recent libnbd 1.4 release has added 'nbd_set_full_info()' as the
knob for whether a client will request name and description during
NBD_OPT_GO; adding this functionality in nbdkit makes it easier to
test that API in libnbd.
---
 docs/nbdkit-filter.pod               |  9 ++++
 docs/nbdkit-plugin.pod               | 20 ++++++++
 docs/nbdkit-protocol.pod             | 14 ++++++
 plugins/eval/nbdkit-eval-plugin.pod  |  2 +
 plugins/sh/nbdkit-sh-plugin.pod      | 11 +++++
 include/nbdkit-filter.h              |  3 ++
 include/nbdkit-plugin.h              |  1 +
 server/internal.h                    |  3 ++
 server/backend.c                     | 22 +++++++++
 server/filters.c                     | 13 +++++
 server/plugins.c                     | 13 +++++
 server/protocol-handshake-newstyle.c | 25 ++++++++--
 plugins/sh/methods.h                 |  1 +
 plugins/cc/cc.c                      | 59 +++++++++++++----------
 plugins/eval/eval.c                  | 70 ++++++++++++++-------------
 plugins/sh/methods.c                 | 32 +++++++++++++
 plugins/sh/sh.c                      | 71 ++++++++++++++--------------
 filters/tls-fallback/tls-fallback.c  | 42 +++++++++-------
 tests/test-export-info.sh            | 34 ++++++-------
 tests/test-tls-fallback.sh           |  6 +++
 TODO                                 |  5 --
 21 files changed, 320 insertions(+), 136 deletions(-)

diff --git a/docs/nbdkit-filter.pod b/docs/nbdkit-filter.pod
index 79c39f0d..b6a73f96 100644
--- a/docs/nbdkit-filter.pod
+++ b/docs/nbdkit-filter.pod
@@ -551,6 +551,15 @@ This function is only called once per connection and cached
by nbdkit.
 Similarly, repeated calls to C<next_ops-E<gt>get_size> will return
a
 cached value.

+=head2 C<.export_description>
+
+ const char *export_description (struct nbdkit_next_ops *next_ops,
+                                 void *nxdata, void *handle);
+
+This intercepts the plugin C<.export_description> method and can be
+used to read or modify the export description that the NBD client
+will see.
+
 =head2 C<.can_write>

 =head2 C<.can_flush>
diff --git a/docs/nbdkit-plugin.pod b/docs/nbdkit-plugin.pod
index 9f5d6356..18412b6a 100644
--- a/docs/nbdkit-plugin.pod
+++ b/docs/nbdkit-plugin.pod
@@ -814,6 +814,26 @@ to get the size (in bytes) of the block device being
exported.
 The returned size must be E<ge> 0.  If there is an error,
C<.get_size>
 should call C<nbdkit_error> with an error message and return C<-1>.

+=head2 C<.export_description>
+
+ const char *export_description (void *handle);
+
+This is called during the option negotiation phase only if the
+client specifically requested an export description (see the
+C<NBD_INFO_DESCRIPTION> response to C<NBD_OPT_GO>).  Any
+description provided must be human-readable UTF-8, no longer
+than 4096 bytes.  Ideally, this description should match any
+description set during C<.list_exports>, but that is not
+enforced.
+
+If the plugin returns C<NULL> or an invalid string (such as longer
+than 4096 bytes), or if this callback is omitted, no description is
+offered to the client.  As this is not an error in the protocol, it is
+not necessary to call C<nbdkit_error>.  If the callback will not be
+returning a compile-time constant string, you may find
+C<nbdkit_strdup_intern> helpful for returning a value that avoids a
+memory leak.
+
 =head2 C<.can_write>

  int can_write (void *handle);
diff --git a/docs/nbdkit-protocol.pod b/docs/nbdkit-protocol.pod
index b923d367..2f46d735 100644
--- a/docs/nbdkit-protocol.pod
+++ b/docs/nbdkit-protocol.pod
@@ -200,6 +200,20 @@ request is no faster than a counterpart write would be,
while normal
 zero requests still benefit from compressed network traffic regardless
 of the time taken.

+=item C<NBD_INFO_NAME>, C<NBD_INFO_DESCRIPTION>
+
+Supported in nbdkit E<ge> 1.22.0.
+
+These protocol extensions allow a client to learn more information
+about an export during C<NBD_OPT_GO>.  The C<.default_export>
callback
+can inform a client of a canonical non-empty name in place of the
+default export C<"">, and the C<.export_description>
callback can give
+a client more details about the export.
+
+=item C<NBD_INFO_BLOCK_SIZE>
+
+I<Not supported>.
+
 =item Resize Extension

 I<Not supported>.
diff --git a/plugins/eval/nbdkit-eval-plugin.pod
b/plugins/eval/nbdkit-eval-plugin.pod
index eb3c7f5a..22167ec5 100644
--- a/plugins/eval/nbdkit-eval-plugin.pod
+++ b/plugins/eval/nbdkit-eval-plugin.pod
@@ -100,6 +100,8 @@ features):

 =item B<dump_plugin=>SCRIPT

+=item B<export_description=>SCRIPT
+
 =item B<extents=>SCRIPT

 =item B<flush=>SCRIPT
diff --git a/plugins/sh/nbdkit-sh-plugin.pod b/plugins/sh/nbdkit-sh-plugin.pod
index c6e9c432..fe86e53c 100644
--- a/plugins/sh/nbdkit-sh-plugin.pod
+++ b/plugins/sh/nbdkit-sh-plugin.pod
@@ -350,6 +350,17 @@ handle will be C<""> (empty string).

  /path/to/script close <handle>

+=item C<export_description>
+
+ /path/to/script export_description <handle>
+
+The script should print a human-readable description of the disk image
+on stdout.  If the description ends with a newline character then the
+newline is removed.
+
+This method is I<not> required; if it is absent, no export description
+will be provided to the client.
+
 =item C<get_size>

  /path/to/script get_size <handle>
diff --git a/include/nbdkit-filter.h b/include/nbdkit-filter.h
index 63458d59..afe83e0b 100644
--- a/include/nbdkit-filter.h
+++ b/include/nbdkit-filter.h
@@ -80,6 +80,7 @@ struct nbdkit_next_ops {

   /* The rest of the next ops are the same as normal plugin operations. */
   int64_t (*get_size) (nbdkit_backend *nxdata);
+  const char * (*export_description) (nbdkit_backend *nxdata);

   int (*can_write) (nbdkit_backend *nxdata);
   int (*can_flush) (nbdkit_backend *nxdata);
@@ -195,6 +196,8 @@ struct nbdkit_filter {

   int64_t (*get_size) (struct nbdkit_next_ops *next_ops, nbdkit_backend
*nxdata,
                        void *handle);
+  const char * (*export_description) (struct nbdkit_next_ops *next_ops,
+                                      nbdkit_backend *nxdata, void *handle);

   int (*can_write) (struct nbdkit_next_ops *next_ops, nbdkit_backend *nxdata,
                     void *handle);
diff --git a/include/nbdkit-plugin.h b/include/nbdkit-plugin.h
index 539e3f6e..db010a5a 100644
--- a/include/nbdkit-plugin.h
+++ b/include/nbdkit-plugin.h
@@ -143,6 +143,7 @@ struct nbdkit_plugin {
   int (*list_exports) (int readonly, int is_tls,
                        struct nbdkit_exports *exports);
   const char * (*default_export) (int readonly, int is_tls);
+  const char * (*export_description) (void *handle);
 };

 NBDKIT_EXTERN_DECL (void, nbdkit_set_error, (int err));
diff --git a/server/internal.h b/server/internal.h
index fe485117..e7619469 100644
--- a/server/internal.h
+++ b/server/internal.h
@@ -379,6 +379,7 @@ struct backend {
   int (*finalize) (struct backend *, void *handle);
   void (*close) (struct backend *, void *handle);

+  const char *(*export_description) (struct backend *, void *handle);
   int64_t (*get_size) (struct backend *, void *handle);
   int (*can_write) (struct backend *, void *handle);
   int (*can_flush) (struct backend *, void *handle);
@@ -423,6 +424,8 @@ extern int backend_list_exports (struct backend *b, int
readonly,
   __attribute__((__nonnull__ (1, 3)));
 extern const char *backend_default_export (struct backend *b, int readonly)
   __attribute__((__nonnull__ (1)));
+extern const char *backend_export_description (struct backend *b)
+  __attribute__((__nonnull__ (1)));
 /* exportname is only valid for this call and almost certainly will be
  * freed on return of this function, so backends must save the
  * exportname if they need to refer to it later.
diff --git a/server/backend.c b/server/backend.c
index 2023c40b..89bf1015 100644
--- a/server/backend.c
+++ b/server/backend.c
@@ -360,6 +360,28 @@ backend_reopen (struct backend *b, int readonly, const char
*exportname)
   return 0;
 }

+const char *
+backend_export_description (struct backend *b)
+{
+  GET_CONN;
+  struct handle *h = get_handle (conn, b->i);
+  const char *s;
+
+  controlpath_debug ("%s: export_description", b->name);
+
+  assert (h->handle && (h->state & HANDLE_CONNECTED));
+  /* Caching is not useful for this value. */
+  s = b->export_description (b, h->handle);
+
+  /* Ignore over-length strings. XXX Also ignore non-UTF8? */
+  if (s && strnlen (s, NBD_MAX_STRING + 1) > NBD_MAX_STRING) {
+    controlpath_debug ("%s: export_description: ignoring invalid
string",
+                       b->name);
+    s = NULL;
+  }
+  return s;
+}
+
 int64_t
 backend_get_size (struct backend *b)
 {
diff --git a/server/filters.c b/server/filters.c
index dd4417be..54abf9a4 100644
--- a/server/filters.c
+++ b/server/filters.c
@@ -291,6 +291,7 @@ filter_close (struct backend *b, void *handle)

 static struct nbdkit_next_ops next_ops = {
   .reopen = backend_reopen,
+  .export_description = backend_export_description,
   .get_size = backend_get_size,
   .can_write = backend_can_write,
   .can_flush = backend_can_flush,
@@ -334,6 +335,17 @@ filter_finalize (struct backend *b, void *handle)
   return 0;
 }

+static const char *
+filter_export_description (struct backend *b, void *handle)
+{
+  struct backend_filter *f = container_of (b, struct backend_filter, backend);
+
+  if (f->filter.export_description)
+    return f->filter.export_description (&next_ops, b->next, handle);
+  else
+    return backend_export_description (b->next);
+}
+
 static int64_t
 filter_get_size (struct backend *b, void *handle)
 {
@@ -571,6 +583,7 @@ static struct backend filter_functions = {
   .prepare = filter_prepare,
   .finalize = filter_finalize,
   .close = filter_close,
+  .export_description = filter_export_description,
   .get_size = filter_get_size,
   .can_write = filter_can_write,
   .can_flush = filter_can_flush,
diff --git a/server/plugins.c b/server/plugins.c
index 8061b2e8..38db3a89 100644
--- a/server/plugins.c
+++ b/server/plugins.c
@@ -167,6 +167,7 @@ plugin_dump_fields (struct backend *b)

   HAS (open);
   HAS (close);
+  HAS (export_description);
   HAS (get_size);
   HAS (can_write);
   HAS (can_flush);
@@ -366,6 +367,17 @@ plugin_close (struct backend *b, void *handle)
   conn->exportname = NULL;
 }

+static const char *
+plugin_export_description (struct backend *b, void *handle)
+{
+  struct backend_plugin *p = container_of (b, struct backend_plugin, backend);
+
+  if (p->plugin.export_description)
+    return p->plugin.export_description (handle);
+  else
+    return NULL;
+}
+
 static int64_t
 plugin_get_size (struct backend *b, void *handle)
 {
@@ -772,6 +784,7 @@ static struct backend plugin_functions = {
   .prepare = plugin_prepare,
   .finalize = plugin_finalize,
   .close = plugin_close,
+  .export_description = plugin_export_description,
   .get_size = plugin_get_size,
   .can_write = plugin_can_write,
   .can_flush = plugin_can_flush,
diff --git a/server/protocol-handshake-newstyle.c
b/server/protocol-handshake-newstyle.c
index 7a034360..6d3e53c7 100644
--- a/server/protocol-handshake-newstyle.c
+++ b/server/protocol-handshake-newstyle.c
@@ -563,10 +563,10 @@ negotiate_handshake_newstyle_options (void)
                                                     exportsize) == -1)
           return -1;

-        /* For now we send NBD_INFO_NAME if requested, and ignore all
-         * other info requests (including NBD_INFO_EXPORT if it was
-         * requested, because we replied already above).
-         * XXX NBD_INFO_DESCRIPTION is easy once we add .export_description.
+        /* For now we send NBD_INFO_NAME and NBD_INFO_DESCRIPTION if
+         * requested, and ignore all other info requests (including
+         * NBD_INFO_EXPORT if it was requested, because we replied
+         * already above).
          */
         for (i = 0; i < nrinfos; ++i) {
           memcpy (&info, &data[4 + exportnamelen + 2 + i*2], 2);
@@ -594,6 +594,23 @@ negotiate_handshake_newstyle_options (void)
                 return -1;
             }
             break;
+          case NBD_INFO_DESCRIPTION:
+            {
+              const char *desc = backend_export_description (top);
+
+              if (!desc) {
+                debug ("newstyle negotiation: %s: "
+                       "NBD_INFO_DESCRIPTION: no description to
send",
+                       optname);
+                break;
+              }
+              if (send_newstyle_option_reply_info_str (option,
+                                                       NBD_REP_INFO,
+                                                       NBD_INFO_DESCRIPTION,
+                                                       desc, -1) == -1)
+                return -1;
+            }
+            break;
           default:
             debug ("newstyle negotiation: %s: "
                    "ignoring NBD_INFO_* request %u (%s)",
diff --git a/plugins/sh/methods.h b/plugins/sh/methods.h
index 3fd4ef42..42eb560c 100644
--- a/plugins/sh/methods.h
+++ b/plugins/sh/methods.h
@@ -49,6 +49,7 @@ extern int sh_list_exports (int readonly, int default_only,
 extern const char *sh_default_export (int readonly, int is_tls);
 extern void *sh_open (int readonly);
 extern void sh_close (void *handle);
+extern const char *sh_export_description (void *handle);
 extern int64_t sh_get_size (void *handle);
 extern int sh_pread (void *handle, void *buf, uint32_t count, uint64_t offset,
                      uint32_t flags);
diff --git a/plugins/cc/cc.c b/plugins/cc/cc.c
index 381d1f7e..ae641088 100644
--- a/plugins/cc/cc.c
+++ b/plugins/cc/cc.c
@@ -377,6 +377,14 @@ cc_close (void *handle)
     subplugin.close (handle);
 }

+static const char *
+cc_export_description (void *handle)
+{
+  if (subplugin.export_description)
+    return subplugin.export_description (handle);
+  return NULL;
+}
+
 static int64_t
 cc_get_size (void *handle)
 {
@@ -575,34 +583,35 @@ static struct nbdkit_plugin plugin = {
   /* And we must provide callbacks for everything else, which are
    * passed through to the subplugin.
    */
-  .get_ready         = cc_get_ready,
-  .after_fork        = cc_after_fork,
+  .get_ready          = cc_get_ready,
+  .after_fork         = cc_after_fork,

-  .preconnect        = cc_preconnect,
-  .list_exports      = cc_list_exports,
-  .default_export    = cc_default_export,
-  .open              = cc_open,
-  .close             = cc_close,
+  .preconnect         = cc_preconnect,
+  .list_exports       = cc_list_exports,
+  .default_export     = cc_default_export,
+  .open               = cc_open,
+  .close              = cc_close,

-  .get_size          = cc_get_size,
-  .can_write         = cc_can_write,
-  .can_flush         = cc_can_flush,
-  .is_rotational     = cc_is_rotational,
-  .can_trim          = cc_can_trim,
-  .can_zero          = cc_can_zero,
-  .can_fast_zero     = cc_can_fast_zero,
-  .can_extents       = cc_can_extents,
-  .can_fua           = cc_can_fua,
-  .can_multi_conn    = cc_can_multi_conn,
-  .can_cache         = cc_can_cache,
+  .export_description = cc_export_description,
+  .get_size           = cc_get_size,
+  .can_write          = cc_can_write,
+  .can_flush          = cc_can_flush,
+  .is_rotational      = cc_is_rotational,
+  .can_trim           = cc_can_trim,
+  .can_zero           = cc_can_zero,
+  .can_fast_zero      = cc_can_fast_zero,
+  .can_extents        = cc_can_extents,
+  .can_fua            = cc_can_fua,
+  .can_multi_conn     = cc_can_multi_conn,
+  .can_cache          = cc_can_cache,

-  .pread             = cc_pread,
-  .pwrite            = cc_pwrite,
-  .flush             = cc_flush,
-  .trim              = cc_trim,
-  .zero              = cc_zero,
-  .extents           = cc_extents,
-  .cache             = cc_cache,
+  .pread              = cc_pread,
+  .pwrite             = cc_pwrite,
+  .flush              = cc_flush,
+  .trim               = cc_trim,
+  .zero               = cc_zero,
+  .extents            = cc_extents,
+  .cache              = cc_cache,

   .errno_is_preserved = 1,
 };
diff --git a/plugins/eval/eval.c b/plugins/eval/eval.c
index a123734e..ae84bd40 100644
--- a/plugins/eval/eval.c
+++ b/plugins/eval/eval.c
@@ -70,6 +70,7 @@ static const char *known_methods[] = {
   "config_complete",
   "default_export",
   "dump_plugin",
+  "export_description",
   "extents",
   "flush",
   "get_ready",
@@ -380,45 +381,46 @@ eval_config_complete (void)
 #define THREAD_MODEL NBDKIT_THREAD_MODEL_PARALLEL

 static struct nbdkit_plugin plugin = {
-  .name              = "eval",
-  .version           = PACKAGE_VERSION,
-  .load              = eval_load,
-  .unload            = eval_unload,
+  .name               = "eval",
+  .version            = PACKAGE_VERSION,
+  .load               = eval_load,
+  .unload             = eval_unload,

-  .dump_plugin       = sh_dump_plugin,
+  .dump_plugin        = sh_dump_plugin,

-  .config            = eval_config,
-  .config_complete   = eval_config_complete,
-  .config_help       = eval_config_help,
-  .thread_model      = sh_thread_model,
-  .get_ready         = sh_get_ready,
-  .after_fork        = sh_after_fork,
+  .config             = eval_config,
+  .config_complete    = eval_config_complete,
+  .config_help        = eval_config_help,
+  .thread_model       = sh_thread_model,
+  .get_ready          = sh_get_ready,
+  .after_fork         = sh_after_fork,

-  .preconnect        = sh_preconnect,
-  .list_exports      = sh_list_exports,
-  .default_export    = sh_default_export,
-  .open              = sh_open,
-  .close             = sh_close,
+  .preconnect         = sh_preconnect,
+  .list_exports       = sh_list_exports,
+  .default_export     = sh_default_export,
+  .open               = sh_open,
+  .close              = sh_close,

-  .get_size          = sh_get_size,
-  .can_write         = sh_can_write,
-  .can_flush         = sh_can_flush,
-  .is_rotational     = sh_is_rotational,
-  .can_trim          = sh_can_trim,
-  .can_zero          = sh_can_zero,
-  .can_extents       = sh_can_extents,
-  .can_fua           = sh_can_fua,
-  .can_multi_conn    = sh_can_multi_conn,
-  .can_cache         = sh_can_cache,
-  .can_fast_zero     = sh_can_fast_zero,
+  .export_description = sh_export_description,
+  .get_size           = sh_get_size,
+  .can_write          = sh_can_write,
+  .can_flush          = sh_can_flush,
+  .is_rotational      = sh_is_rotational,
+  .can_trim           = sh_can_trim,
+  .can_zero           = sh_can_zero,
+  .can_extents        = sh_can_extents,
+  .can_fua            = sh_can_fua,
+  .can_multi_conn     = sh_can_multi_conn,
+  .can_cache          = sh_can_cache,
+  .can_fast_zero      = sh_can_fast_zero,

-  .pread             = sh_pread,
-  .pwrite            = sh_pwrite,
-  .flush             = sh_flush,
-  .trim              = sh_trim,
-  .zero              = sh_zero,
-  .extents           = sh_extents,
-  .cache             = sh_cache,
+  .pread              = sh_pread,
+  .pwrite             = sh_pwrite,
+  .flush              = sh_flush,
+  .trim               = sh_trim,
+  .zero               = sh_zero,
+  .extents            = sh_extents,
+  .cache              = sh_cache,

   .errno_is_preserved = 1,
 };
diff --git a/plugins/sh/methods.c b/plugins/sh/methods.c
index a0446fb2..beafcd25 100644
--- a/plugins/sh/methods.c
+++ b/plugins/sh/methods.c
@@ -457,6 +457,38 @@ sh_close (void *handle)
   }
 }

+const char *
+sh_export_description (void *handle)
+{
+  const char *method = "export_description";
+  const char *script = get_script (method);
+  struct sh_handle *h = handle;
+  const char *args[] = { script, method, h->h, NULL };
+  CLEANUP_FREE char *s = NULL;
+  size_t slen;
+
+  switch (call_read (&s, &slen, args)) {
+  case OK:
+    if (slen > 0 && s[slen-1] == '\n')
+      s[slen-1] = '\0';
+    return nbdkit_strdup_intern (s);
+
+  case MISSING:
+    return NULL;
+
+  case ERROR:
+    return NULL;
+
+  case RET_FALSE:
+    nbdkit_error ("%s: %s method returned unexpected code (3/false)",
+                  script, method);
+    errno = EIO;
+    return NULL;
+
+  default: abort ();
+  }
+}
+
 int64_t
 sh_get_size (void *handle)
 {
diff --git a/plugins/sh/sh.c b/plugins/sh/sh.c
index 4fcc2a5a..db75d386 100644
--- a/plugins/sh/sh.c
+++ b/plugins/sh/sh.c
@@ -284,46 +284,47 @@ sh_config_complete (void)
 #define THREAD_MODEL NBDKIT_THREAD_MODEL_PARALLEL

 static struct nbdkit_plugin plugin = {
-  .name              = "sh",
-  .version           = PACKAGE_VERSION,
-  .load              = sh_load,
-  .unload            = sh_unload,
+  .name               = "sh",
+  .version            = PACKAGE_VERSION,
+  .load               = sh_load,
+  .unload             = sh_unload,

-  .dump_plugin       = sh_dump_plugin,
+  .dump_plugin        = sh_dump_plugin,

-  .config            = sh_config,
-  .config_complete   = sh_config_complete,
-  .config_help       = sh_config_help,
-  .magic_config_key  = "script",
-  .thread_model      = sh_thread_model,
-  .get_ready         = sh_get_ready,
-  .after_fork        = sh_after_fork,
+  .config             = sh_config,
+  .config_complete    = sh_config_complete,
+  .config_help        = sh_config_help,
+  .magic_config_key   = "script",
+  .thread_model       = sh_thread_model,
+  .get_ready          = sh_get_ready,
+  .after_fork         = sh_after_fork,

-  .preconnect        = sh_preconnect,
-  .list_exports      = sh_list_exports,
-  .default_export    = sh_default_export,
-  .open              = sh_open,
-  .close             = sh_close,
+  .preconnect         = sh_preconnect,
+  .list_exports       = sh_list_exports,
+  .default_export     = sh_default_export,
+  .open               = sh_open,
+  .close              = sh_close,

-  .get_size          = sh_get_size,
-  .can_write         = sh_can_write,
-  .can_flush         = sh_can_flush,
-  .is_rotational     = sh_is_rotational,
-  .can_trim          = sh_can_trim,
-  .can_zero          = sh_can_zero,
-  .can_extents       = sh_can_extents,
-  .can_fua           = sh_can_fua,
-  .can_multi_conn    = sh_can_multi_conn,
-  .can_cache         = sh_can_cache,
-  .can_fast_zero     = sh_can_fast_zero,
+  .export_description = sh_export_description,
+  .get_size           = sh_get_size,
+  .can_write          = sh_can_write,
+  .can_flush          = sh_can_flush,
+  .is_rotational      = sh_is_rotational,
+  .can_trim           = sh_can_trim,
+  .can_zero           = sh_can_zero,
+  .can_extents        = sh_can_extents,
+  .can_fua            = sh_can_fua,
+  .can_multi_conn     = sh_can_multi_conn,
+  .can_cache          = sh_can_cache,
+  .can_fast_zero      = sh_can_fast_zero,

-  .pread             = sh_pread,
-  .pwrite            = sh_pwrite,
-  .flush             = sh_flush,
-  .trim              = sh_trim,
-  .zero              = sh_zero,
-  .extents           = sh_extents,
-  .cache             = sh_cache,
+  .pread              = sh_pread,
+  .pwrite             = sh_pwrite,
+  .flush              = sh_flush,
+  .trim               = sh_trim,
+  .zero               = sh_zero,
+  .extents            = sh_extents,
+  .cache              = sh_cache,

   .errno_is_preserved = 1,
 };
diff --git a/filters/tls-fallback/tls-fallback.c
b/filters/tls-fallback/tls-fallback.c
index c1e549df..bec983be 100644
--- a/filters/tls-fallback/tls-fallback.c
+++ b/filters/tls-fallback/tls-fallback.c
@@ -119,6 +119,15 @@ tls_fallback_open (nbdkit_next_open *next, void *nxdata,
int readonly,
  * can_zero, can_fast_zero, and can_fua).
  */

+static const char *
+tls_fallback_export_description (struct nbdkit_next_ops *next_ops,
+                                 void *nxdata, void *handle)
+{
+  if (NOT_TLS)
+    return NULL;
+  return next_ops->export_description (nxdata);
+}
+
 static int64_t
 tls_fallback_get_size (struct nbdkit_next_ops *next_ops, void *nxdata,
                        void *handle)
@@ -195,22 +204,23 @@ tls_fallback_pread (struct nbdkit_next_ops *next_ops, void
*nxdata,
 }

 static struct nbdkit_filter filter = {
-  .name              = "tls-fallback",
-  .longname          = "nbdkit tls-fallback filter",
-  .config            = tls_fallback_config,
-  .config_help       = tls_fallback_config_help,
-  .get_ready         = tls_fallback_get_ready,
-  .list_exports      = tls_fallback_list_exports,
-  .default_export    = tls_fallback_default_export,
-  .open              = tls_fallback_open,
-  .get_size          = tls_fallback_get_size,
-  .can_write         = tls_fallback_can_write,
-  .can_flush         = tls_fallback_can_flush,
-  .is_rotational     = tls_fallback_is_rotational,
-  .can_extents       = tls_fallback_can_extents,
-  .can_multi_conn    = tls_fallback_can_multi_conn,
-  .can_cache         = tls_fallback_can_cache,
-  .pread             = tls_fallback_pread,
+  .name               = "tls-fallback",
+  .longname           = "nbdkit tls-fallback filter",
+  .config             = tls_fallback_config,
+  .config_help        = tls_fallback_config_help,
+  .get_ready          = tls_fallback_get_ready,
+  .list_exports       = tls_fallback_list_exports,
+  .default_export     = tls_fallback_default_export,
+  .open               = tls_fallback_open,
+  .export_description = tls_fallback_export_description,
+  .get_size           = tls_fallback_get_size,
+  .can_write          = tls_fallback_can_write,
+  .can_flush          = tls_fallback_can_flush,
+  .is_rotational      = tls_fallback_is_rotational,
+  .can_extents        = tls_fallback_can_extents,
+  .can_multi_conn     = tls_fallback_can_multi_conn,
+  .can_cache          = tls_fallback_can_cache,
+  .pread              = tls_fallback_pread,
 };

 NBDKIT_REGISTER_FILTER(filter)
diff --git a/tests/test-export-info.sh b/tests/test-export-info.sh
index 984d86c8..5d390835 100755
--- a/tests/test-export-info.sh
+++ b/tests/test-export-info.sh
@@ -43,7 +43,6 @@ rm -f $files
 cleanup_fn rm -f $files

 # Create an nbdkit sh plugin for checking NBD_INFO replies to NBD_OPT_GO.
-# XXX Update when .export_description is implemented in sh
 start_nbdkit -P export-info.pid -U $sock \
              sh - <<'EOF'
 case "$1" in
@@ -59,36 +58,34 @@ EOF
 nbdsh -c '
 import os

+def must_fail (f, *args, **kwds):
+  try:
+    f ( *args, **kwds)
+    assert False
+  except nbd.Error as ex:
+    pass
+
 h.set_opt_mode (True)
 h.connect_unix (os.environ["sock"])

 h.set_export_name ("a")
 h.opt_info ()
-try:
-  h.get_canonical_export_name ()
-  assert False
-except nbd.Error as ex:
-  pass
+must_fail (h.get_canonical_export_name)
+must_fail (h.get_export_description)

 h.set_export_name ("")
 h.opt_info ()
-try:
-  h.get_canonical_export_name ()
-  assert False
-except nbd.Error as ex:
-  pass
+must_fail (h.get_canonical_export_name)
+must_fail (h.get_export_description)

 h.opt_go ()
-try:
-  h.get_canonical_export_name ()
-  assert False
-except nbd.Error as ex:
-  pass
+must_fail (h.get_canonical_export_name)
+must_fail (h.get_export_description)

 h.shutdown ()
 '

-# With client request, reflect the export name back
+# With client request, reflect the export name and description back
 nbdsh -c '
 import os

@@ -99,13 +96,16 @@ h.connect_unix (os.environ["sock"])
 h.set_export_name ("a")
 h.opt_info ()
 assert h.get_canonical_export_name () == "a"
+assert h.get_export_description () == "a world"

 h.set_export_name ("")
 h.opt_info ()
 assert h.get_canonical_export_name () == "hello"
+assert h.get_export_description () == "hello world"

 h.opt_go ()
 assert h.get_canonical_export_name () == "hello"
+assert h.get_export_description () == "hello world"

 h.shutdown ()
 '
diff --git a/tests/test-tls-fallback.sh b/tests/test-tls-fallback.sh
index fb232bf9..b6c92918 100755
--- a/tests/test-tls-fallback.sh
+++ b/tests/test-tls-fallback.sh
@@ -93,6 +93,11 @@ h.connect_unix (os.environ["sock"])
 assert h.opt_list (f) == 1
 h.opt_info ()
 assert h.get_canonical_export_name() == ""
+try:
+  h.get_export_description()
+  assert False
+except nbd.Error as ex:
+  pass
 h.set_export_name ("hello")
 h.opt_go ()
 assert h.get_size () == 512
@@ -121,6 +126,7 @@ h.connect_unix (os.environ["sock"])
 assert h.opt_list (f) == 2
 h.opt_info ()
 assert h.get_canonical_export_name() == "hello"
+assert h.get_export_description() == "=hello="
 h.opt_go ()
 assert h.get_size () == 6
 assert h.can_trim ()
diff --git a/TODO b/TODO
index e4dd072f..ff902c7d 100644
--- a/TODO
+++ b/TODO
@@ -71,11 +71,6 @@ General ideas for improvements
   continue to keep their non-standard handshake while utilizing nbdkit
   to prototype new behaviors in serving the kernel.

-* At present, we ignore NBD_INFO_DESCRIPTION requests from a client
-  during NBD_OPT_GO.  This may be easiest if we add a new
-  .get_description callback, called after .open alongside .get_size
-  and .can_write.
-
 * Background thread for filters.  Some filters (readahead, cache and
   proposed scan filter - see below) could be more effective if they
   were able to defer work to a background thread.  However it's not as
--
2.28.0