From 7a4d4ebef975f03fef3353d76274fade61b9eaf8 Mon Sep 17 00:00:00 2001 From: Martin Belanger Date: Wed, 1 Jul 2026 18:55:14 -0400 Subject: [PATCH 1/9] libnvme: drop the log messages from the no-netdb stubs When the global context became mandatory, three libnvme_msg() call sites in the no-netdb / no-getifaddrs fallback stubs kept passing NULL. __libnvme_msg() dereferences it (struct libnvme_log *l = &ctx->log), so each stub crashes in any build without netdb (e.g. musl built with -U_GNU_SOURCE). No test exercised those stubs until the exclusion-list unit tests started calling libnvme_ipaddrs_eq(). Rather than plumb a context into the stubs just to emit a diagnostic, drop the messages: no-netdb is a corner-case configuration and the stubs' return values already tell the story. Suggested-by: Daniel Wagner Signed-off-by: Martin Belanger --- libnvme/src/nvme/util.c | 9 --------- 1 file changed, 9 deletions(-) diff --git a/libnvme/src/nvme/util.c b/libnvme/src/nvme/util.c index 9d8be09fa3..11e9858d25 100644 --- a/libnvme/src/nvme/util.c +++ b/libnvme/src/nvme/util.c @@ -766,9 +766,6 @@ bool libnvme_ipaddrs_eq(const char *addr1, const char *addr2) #else /* NVME_HAVE_NETDB */ bool libnvme_ipaddrs_eq(const char *addr1, const char *addr2) { - libnvme_msg(NULL, LIBNVME_LOG_ERR, "no support for hostname ip address resolution; " \ - "recompile with libnss support.\n"); - return false; } #endif /* NVME_HAVE_NETDB */ @@ -836,18 +833,12 @@ bool libnvme_iface_primary_addr_matches(const struct ifaddrs *iface_list, const char *libnvme_iface_matching_addr(const struct ifaddrs *iface_list, const char *addr) { - libnvme_msg(NULL, LIBNVME_LOG_ERR, "no support for interface lookup; " - "recompile with libnss support.\n"); - return NULL; } bool libnvme_iface_primary_addr_matches(const struct ifaddrs *iface_list, const char *iface, const char *addr) { - libnvme_msg(NULL, LIBNVME_LOG_ERR, "no support for interface lookup; " - "recompile with libnss support.\n"); - return false; } From 074830fd83cb8297f2a5ddb9a5bda5768f91affc Mon Sep 17 00:00:00 2001 From: Martin Belanger Date: Mon, 29 Jun 2026 09:30:58 -0400 Subject: [PATCH 2/9] libnvme: add shared fabrics helpers in preparation for exclusion list This is a preparation patch for the NVMe-oF exclusion list added in a following commit. It introduces utility helpers shared between the registry and the exclusion list. registry.c keeps private helpers to create directories (mkdir -p), write a file atomically via a cloexec temp file, and validate a test-only directory override. The exclusion list needs the same primitives, so move them into util-fabrics.c as internal libnvmf_ helpers and convert registry.c to use them. No functional change: the registry behaviour and unit tests are unchanged. Signed-off-by: Martin Belanger --- libnvme/libnvme/tests/test-registry.py | 89 ++++++------ libnvme/src/libnvme.ld | 1 + libnvme/src/nvme/lib.c | 41 ++++++ libnvme/src/nvme/lib.h | 16 +++ libnvme/src/nvme/log.c | 30 ---- libnvme/src/nvme/private-fabrics.h | 13 ++ libnvme/src/nvme/private.h | 4 + libnvme/src/nvme/registry.c | 186 ++++++++----------------- libnvme/src/nvme/util-fabrics.c | 79 +++++++++++ libnvme/src/nvme/util.c | 23 +++ libnvme/test/registry.c | 29 ++-- tests/nvme_registry_test.py | 24 ++-- 12 files changed, 317 insertions(+), 218 deletions(-) diff --git a/libnvme/libnvme/tests/test-registry.py b/libnvme/libnvme/tests/test-registry.py index 9c57e8ee8a..c02e94857d 100644 --- a/libnvme/libnvme/tests/test-registry.py +++ b/libnvme/libnvme/tests/test-registry.py @@ -2,49 +2,57 @@ # SPDX-License-Identifier: LGPL-2.1-or-later """Unit tests for the registry Python bindings. -NVME_REGISTRY_DIR must be set before importing libnvme so that _registry_dir -is cached with the test path at module load time. This module sets it at the -top level before the import. +All tests share a single /tmp sandbox directory, applied to each GlobalCtx via +ctx.set_test_base_dir(). This mirrors production, where there is exactly one +registry directory. """ import multiprocessing import os import tempfile import unittest +from libnvme import nvme + # meson sets VALGRIND_OPTS when running under valgrind. Forked child # processes under valgrind can behave unexpectedly; the parallel write # test is already covered by the C test suite (libnvme/test/registry.c). _under_valgrind = 'VALGRIND_OPTS' in os.environ -# dir='/tmp' is required: libnvme confines NVME_REGISTRY_DIR to /tmp, so the -# test directory must live there (not under an arbitrary $TMPDIR). -# -# Reuse an inherited /tmp directory instead of always creating a new one: with -# the spawn/forkserver start methods (the default on Linux as of Python 3.14) -# each child re-imports this module. The child inherits NVME_REGISTRY_DIR from -# the parent, so it must reuse that path rather than create a second, unrelated -# registry directory it would then write into alone. -_tmpdir = os.environ.get('NVME_REGISTRY_DIR', '') -if not _tmpdir.startswith('/tmp/'): - _tmpdir = tempfile.mkdtemp(prefix='nvme-registry-test-', dir='/tmp') - os.environ['NVME_REGISTRY_DIR'] = _tmpdir +# A single sandbox shared by every test (one registry dir, as in production). +# dir='/tmp' is required: libnvme confines the test base dir to /tmp. +_tmpdir = None +_regdir = None + -from libnvme import nvme # noqa: E402 (import after env var set intentionally) +def setUpModule(): + global _tmpdir, _regdir + _tmpdir = tempfile.mkdtemp(prefix='nvme-registry-test-', dir='/tmp') + _regdir = os.path.join(_tmpdir, 'registry') -def _teardown_tmpdir(): +def tearDownModule(): """Remove the test registry directory tree.""" - for entry in os.scandir(_tmpdir): - if entry.is_dir(): - for attr in os.scandir(entry.path): - os.unlink(attr.path) - os.rmdir(entry.path) - os.rmdir(_tmpdir) + if _regdir and os.path.isdir(_regdir): + for entry in os.scandir(_regdir): + if entry.is_dir(): + for attr in os.scandir(entry.path): + os.unlink(attr.path) + os.rmdir(entry.path) + os.rmdir(_regdir) + if _tmpdir: + os.rmdir(_tmpdir) + + +def _new_ctx(tmpdir): + """Create a GlobalCtx pointed at the shared /tmp sandbox.""" + ctx = nvme.GlobalCtx() + assert ctx.set_test_base_dir(tmpdir) == 0 + return ctx class TestRegistryUpdate(unittest.TestCase): def setUp(self): - self.ctx = nvme.GlobalCtx() + self.ctx = _new_ctx(_tmpdir) def tearDown(self): nvme.registry_delete(self.ctx, 'nvme5') @@ -70,7 +78,7 @@ def test_update_multiple_attrs(self): class TestRegistryRetrieve(unittest.TestCase): def setUp(self): - self.ctx = nvme.GlobalCtx() + self.ctx = _new_ctx(_tmpdir) def tearDown(self): self.ctx = None @@ -88,7 +96,7 @@ def test_retrieve_missing_attr_returns_none(self): class TestRegistryDelete(unittest.TestCase): def setUp(self): - self.ctx = nvme.GlobalCtx() + self.ctx = _new_ctx(_tmpdir) def tearDown(self): self.ctx = None @@ -109,7 +117,7 @@ class TestRegistryEntries(unittest.TestCase): an iterable.""" def setUp(self): - self.ctx = nvme.GlobalCtx() + self.ctx = _new_ctx(_tmpdir) nvme.registry_update(self.ctx, 'nvme1', 'owner', 'stas') nvme.registry_update(self.ctx, 'nvme2', 'owner', 'nbft') @@ -120,7 +128,8 @@ def tearDown(self): def test_entries_returns_iterable(self): entries = list(nvme.registry_entries(self.ctx)) - # All entries are stale (no /dev/nvme* in test environment) — list is empty. + # All entries are stale (no /dev/nvme* in test environment) — list + # is empty. self.assertIsInstance(entries, list) def test_entries_skips_stale(self): @@ -128,15 +137,17 @@ def test_entries_skips_stale(self): self.assertTrue(os.path.exists('/dev/' + device)) -def _writer(device, owner, iterations): +def _writer(device, owner, iterations, tmpdir): """Child process: repeatedly update the owner attribute. - Each process creates its own GlobalCtx. A libnvme context must not be - shared across a process boundary: passing it as a Process argument is not - picklable under the spawn/forkserver start methods, and even under fork a - context is not designed to be used concurrently from two processes. + Each process creates its own GlobalCtx pointed at the shared sandbox. A + libnvme context must not be shared across a process boundary: passing it as + a Process argument is not picklable under the spawn/forkserver start + methods, and even under fork a context is not designed to be used + concurrently from two processes. The sandbox path is passed explicitly so + the child writes into the same registry directory as the parent. """ - ctx = nvme.GlobalCtx() + ctx = _new_ctx(tmpdir) for _ in range(iterations): nvme.registry_update(ctx, device, 'owner', owner) @@ -146,7 +157,7 @@ class TestRegistryParallelWrites(unittest.TestCase): the registry. The atomic tmp->rename write protocol must ensure the final value is always one of the two written strings.""" def setUp(self): - self.ctx = nvme.GlobalCtx() + self.ctx = _new_ctx(_tmpdir) def tearDown(self): self.ctx = None @@ -158,7 +169,8 @@ def test_parallel_writes_no_corruption(self): nvme.registry_update(self.ctx, 'nvme10', 'owner', 'parent') - procs = [multiprocessing.Process(target=_writer, args=('nvme10', owner, 200)) + procs = [multiprocessing.Process(target=_writer, + args=('nvme10', owner, 200, _tmpdir)) for owner in owners] for p in procs: p.start() @@ -172,7 +184,4 @@ def test_parallel_writes_no_corruption(self): if __name__ == '__main__': - try: - unittest.main() - finally: - _teardown_tmpdir() + unittest.main() diff --git a/libnvme/src/libnvme.ld b/libnvme/src/libnvme.ld index 9c7a10dd34..94d716a87a 100644 --- a/libnvme/src/libnvme.ld +++ b/libnvme/src/libnvme.ld @@ -149,6 +149,7 @@ LIBNVME_3 { libnvme_set_logging_file; libnvme_set_logging_level; libnvme_set_owner; + libnvme_set_test_base_dir; libnvme_skip_namespaces; libnvme_status_to_errno; libnvme_status_to_string; diff --git a/libnvme/src/nvme/lib.c b/libnvme/src/nvme/lib.c index 059aebf929..faa27a1267 100644 --- a/libnvme/src/nvme/lib.c +++ b/libnvme/src/nvme/lib.c @@ -39,9 +39,20 @@ static bool libnvme_mi_probe_enabled_default(void) } +/* + * A test base directory is accepted only when it is confined to /tmp and free + * of ".." components, so it can never redirect libnvme onto a production or + * system path (/etc, /usr, ...). Shared by ctx creation and the setter. + */ +static bool is_valid_test_base_dir(const char *path) +{ + return path && !strncmp(path, "/tmp/", 5) && !strstr(path, ".."); +} + __libnvme_public struct libnvme_global_ctx *libnvme_create_global_ctx(void) { struct libnvme_global_ctx *ctx; + const char *base; ctx = calloc(1, sizeof(*ctx)); if (!ctx) @@ -56,6 +67,16 @@ __libnvme_public struct libnvme_global_ctx *libnvme_create_global_ctx(void) ctx->ioctl_probing = true; ctx->mi_probe_enabled = libnvme_mi_probe_enabled_default(); + /* + * Optional test sandbox: LIBNVME_TEST_BASE_DIR reroots libnvme's + * on-disk files (exclusion list, registry, ...) under a throwaway + * directory. This is the injection point for the shell-invoked nvme + * binary's functional tests; code should prefer the setter below. + */ + base = getenv("LIBNVME_TEST_BASE_DIR"); + if (is_valid_test_base_dir(base)) + ctx->test_base_dir = strdup(base); /* NULL on OOM = prod */ + return ctx; } @@ -74,6 +95,25 @@ __libnvme_public int libnvme_set_owner(struct libnvme_global_ctx *ctx, return 0; } +__libnvme_public int libnvme_set_test_base_dir(struct libnvme_global_ctx *ctx, + const char *path) +{ + char *dup = NULL; + + if (!ctx) + return -EINVAL; + if (path) { + if (!is_valid_test_base_dir(path)) + return -EINVAL; + dup = strdup(path); + if (!dup) + return -ENOMEM; + } + free(ctx->test_base_dir); + ctx->test_base_dir = dup; + return 0; +} + __libnvme_public void libnvme_free_global_ctx(struct libnvme_global_ctx *ctx) { struct libnvme_host *h, *_h; @@ -98,6 +138,7 @@ __libnvme_public void libnvme_free_global_ctx(struct libnvme_global_ctx *ctx) #endif free(ctx->config_file); free(ctx->owner); + free(ctx->test_base_dir); free(ctx); } diff --git a/libnvme/src/nvme/lib.h b/libnvme/src/nvme/lib.h index fcf090dd18..8bb9951656 100644 --- a/libnvme/src/nvme/lib.h +++ b/libnvme/src/nvme/lib.h @@ -59,6 +59,22 @@ void libnvme_free_global_ctx(struct libnvme_global_ctx *ctx); */ int libnvme_set_owner(struct libnvme_global_ctx *ctx, const char *owner); +/** + * libnvme_set_test_base_dir() - Reroot libnvme's on-disk files for testing + * @ctx: &struct libnvme_global_ctx object + * @path: Sandbox directory under /tmp, or NULL to restore defaults + * + * Redirects the files libnvme reads and writes (the exclusion list, the + * ownership registry, ...) under @path instead of their production locations, + * so a test can run against a throwaway directory. For safety @path must be + * confined to /tmp and contain no ".." component; anything else is rejected. + * Passing NULL clears a previously set override. + * + * Return: 0 on success, -EINVAL if @ctx is NULL or @path is not a valid + * sandbox path, -ENOMEM on allocation failure. + */ +int libnvme_set_test_base_dir(struct libnvme_global_ctx *ctx, const char *path); + /** * libnvme_set_logging_level() - Set current logging level * @ctx: struct libnvme_global_ctx object diff --git a/libnvme/src/nvme/log.c b/libnvme/src/nvme/log.c index cafb03be3e..72af117530 100644 --- a/libnvme/src/nvme/log.c +++ b/libnvme/src/nvme/log.c @@ -27,36 +27,6 @@ #define LOG_CLOCK CLOCK_MONOTONIC #endif -static ssize_t write_all(int fd, const void *buf, size_t count) -{ - const char *p = buf; - size_t total = 0; - - while (total < count) { - ssize_t n = write(fd, p + total, count - total); - - if (n > 0) { - total += n; - continue; - } - - if (n < 0) { - if (errno == EINTR) - continue; - - if (errno == EAGAIN) - continue; - - return -1; - } - - errno = EIO; - return -1; - } - - return total; -} - void __libnvme_printf_format(4, 5) __libnvme_msg(struct libnvme_global_ctx *ctx, int level, const char *func, const char *format, ...) diff --git a/libnvme/src/nvme/private-fabrics.h b/libnvme/src/nvme/private-fabrics.h index 6055ea101c..6bb89795d6 100644 --- a/libnvme/src/nvme/private-fabrics.h +++ b/libnvme/src/nvme/private-fabrics.h @@ -215,6 +215,19 @@ size_t libnvmf_get_entity_name(char *buffer, size_t bufsz); */ size_t libnvmf_get_entity_version(char *buffer, size_t bufsz); +/* + * File-access helpers shared across the fabrics layer (util-fabrics.c). + * + * libnvmf_mkdir_p() - mkdir -p; returns 0 or -errno. + * libnvmf_mkstemp() - mkstemp() with close-on-exec (always); + * returns fd or -errno. + * libnvmf_validate_test_dir() - Ensure test dir override is confined to /tmp + * and has no "..". + */ +int libnvmf_mkdir_p(const char *path, mode_t mode); +int libnvmf_mkstemp(char *template); +const char *libnvmf_validate_test_dir(const char *envar); + /** * libnvmf_registry_create_instance - Write a registry entry for a freshly * connected controller. Called from the connect path once the kernel returns diff --git a/libnvme/src/nvme/private.h b/libnvme/src/nvme/private.h index d84ab763ad..3b655d640d 100644 --- a/libnvme/src/nvme/private.h +++ b/libnvme/src/nvme/private.h @@ -433,6 +433,7 @@ struct libnvme_fabric_options { // !generate-accessors struct libnvme_global_ctx { // !generate-python:alias=GlobalCtx char *config_file; char *owner; /* orchestrator identity; NULL = unowned */ + char *test_base_dir; /* test sandbox under /tmp; NULL = prod */ struct list_head endpoints; /* MI endpoints */ struct list_head hosts; struct libnvme_log log; @@ -525,6 +526,9 @@ __libnvme_msg(struct libnvme_global_ctx *ctx, int level, int __libnvmf_import_keys_from_config(struct libnvme_host *h, struct libnvme_ctrl *c, long *keyring_id, long *key_id); +/* write() may return short; loop until the whole buffer is written (util.c). */ +int write_all(int fd, const void *buf, size_t len); + static inline char *xstrdup(const char *s) { if (!s) diff --git a/libnvme/src/nvme/registry.c b/libnvme/src/nvme/registry.c index 0516a6e085..3f80924268 100644 --- a/libnvme/src/nvme/registry.c +++ b/libnvme/src/nvme/registry.c @@ -21,44 +21,35 @@ #include "cleanup.h" #include "compiler-attributes.h" #include "private.h" +#include "private-fabrics.h" #include "registry.h" +#define REGISTRY_DIR_DEFAULT RUNDIR "/nvme/registry" + /* - * Return the root directory of the registry. In production this is always - * "/run/nvme/registry". The NVME_REGISTRY_DIR override exists solely for unit - * testing: it lets the test suite run against a throwaway directory under /tmp - * instead of the production location. The result is cached on first use. + * Root directory of the registry. In production this is REGISTRY_DIR_DEFAULT. + * A ctx test sandbox (libnvme_set_test_base_dir()) + * reroots it to /registry; that path is built once and cached in + * a static. The cache is safe because the sandbox is a single-process test + * feature (see the matching note in exclusion.c): a process is either + * all-production or all-test, so the first call fixes the value for the run. */ -static const char *registry_dir(void) +static const char *registry_dir(struct libnvme_global_ctx *ctx) { - static char buf[256]; - static const char *dir; - - if (!dir) { - const char *env = getenv("NVME_REGISTRY_DIR"); + static const char *cache; + static char buf[PATH_MAX]; + int n; - /* - * NVME_REGISTRY_DIR is a test-only override. The registry - * path drives mkdir, writes and a recursive delete, so an - * attacker who can inject it into a privileged process must - * not redirect those to an arbitrary location. Confine the - * override to /tmp and reject ".." traversal; anything else - * falls back to the production directory. - * - * Copy into a static buffer: getenv()'s pointer can be - * invalidated by a later setenv()/putenv(). Truncation is - * harmless -- a too-long test path simply won't resolve, and a - * malicious value is both truncated and rejected above. - */ - if (env && strncmp(env, "/tmp/", 5) == 0 && - !strstr(env, "..")) { - snprintf(buf, sizeof(buf), "%s", env); - dir = buf; - } else { - dir = "/run/nvme/registry"; - } + if (cache) + return cache; + if (!ctx->test_base_dir) { + cache = REGISTRY_DIR_DEFAULT; + return cache; } - return dir; + /* Sandbox: test_base_dir is a validated /tmp path; always fits. */ + n = snprintf(buf, sizeof(buf), "%s/registry", ctx->test_base_dir); + cache = (n > 0 && (size_t)n < sizeof(buf)) ? buf : REGISTRY_DIR_DEFAULT; + return cache; } /* @@ -66,54 +57,30 @@ static const char *registry_dir(void) * "//". Returns a newly allocated string the * caller must free, or NULL on allocation failure. */ -static char *registry_path(const char *device, const char *attr) +static char *registry_path(struct libnvme_global_ctx *ctx, const char *device, + const char *attr) { char *path = NULL; int n; if (attr) - n = asprintf(&path, "%s/%s/%s", registry_dir(), device, attr); + n = asprintf(&path, "%s/%s/%s", registry_dir(ctx), + device, attr); else - n = asprintf(&path, "%s/%s", registry_dir(), device); + n = asprintf(&path, "%s/%s", registry_dir(ctx), device); return n < 0 ? NULL : path; } -/* mkdir -p: create @path and any missing parents. */ -static int mkdir_p(const char *path, mode_t mode) -{ - __cleanup_free char *tmp = strdup(path); - size_t len; - char *p; - - if (!tmp) - return -ENOMEM; - len = strlen(tmp); - if (len && tmp[len - 1] == '/') - tmp[len - 1] = '\0'; - - for (p = tmp + 1; *p; p++) { - if (*p != '/') - continue; - *p = '\0'; - if (mkdir(tmp, mode) < 0 && errno != EEXIST) - return -errno; - *p = '/'; - } - if (mkdir(tmp, mode) < 0 && errno != EEXIST) - return -errno; - return 0; -} - -static int ensure_device_dir(const char *device) +static int ensure_device_dir(struct libnvme_global_ctx *ctx, const char *device) { __cleanup_free char *path = NULL; int ret; - ret = mkdir_p(registry_dir(), 0755); + ret = libnvmf_mkdir_p(registry_dir(ctx), 0755); if (ret) return ret; - path = registry_path(device, NULL); + path = registry_path(ctx, device, NULL); if (!path) return -ENOMEM; @@ -154,24 +121,6 @@ static bool valid_attr(const char *attr) return true; } -static int write_all(int fd, const char *buf, size_t len) -{ - while (len) { - ssize_t w = write(fd, buf, len); - - if (w < 0) { - if (errno == EINTR) - continue; - return -errno; - } - if (w == 0) - return -EIO; - buf += w; - len -= w; - } - return 0; -} - /* * read() up to @count bytes into @buf, retrying on EINTR. Returns the number * of bytes read (possibly short, at EOF) or a negative errno. @@ -230,19 +179,20 @@ static int read_attr_fd(int fd, char **out) } /* - * Write @value atomically to the attribute file @attr inside @dir_path. + * Write @value atomically to the attribute file @attr inside @dir_path; a NULL + * @value removes the attribute. * - * Protocol: - * mkostemp -> /.tmp.XXXXXX (random name, O_CLOEXEC) - * fchmod -> 0644 - * write -> value + newline - * rename -> / - * fsync -> directory + * Atomicity comes from never mutating the live file in place: @value is + * written to a temp file that is then rename(2)'d onto @attr. rename(2) is an + * atomic replace within a filesystem, so a concurrent reader always sees + * either the complete old file or the complete new one -- never a half-written + * value, and with no locking. The directory fsync makes the rename durable + * across a crash. The random temp name and O_CLOEXEC from libnvmf_mkstemp() are + * secondary: they prevent name prediction / TOCTOU on the temp file and avoid + * leaking the fd across an exec. * - * mkostemp() atomically creates the tmp file with a random suffix, preventing - * both name prediction and TOCTOU races on the tmp file itself. Builds without - * _GNU_SOURCE fall back to mkstemp() + fcntl(FD_CLOEXEC) (see below). A NULL - * @value removes the attribute. + * Steps: mkstemp .tmp.XXXXXX -> fchmod 0644 -> write @value -> rename + * onto -> fsync directory. */ static int write_attr_atomic(int dir_fd, const char *dir_path, const char *attr, const char *value) @@ -266,24 +216,9 @@ static int write_attr_atomic(int dir_fd, const char *dir_path, if (asprintf(&tmp_path, "%s/%s.tmp.XXXXXX", dir_path, attr) < 0) return -ENOMEM; - /* - * mkostemp() sets O_CLOEXEC atomically but its glibc declaration is - * gated behind _GNU_SOURCE; fall back to mkstemp() + fcntl() where - * _GNU_SOURCE is not defined (e.g. the musl-style CI build). - */ -#ifdef _GNU_SOURCE - fd = mkostemp(tmp_path, O_CLOEXEC); + fd = libnvmf_mkstemp(tmp_path); if (fd < 0) - return -errno; -#else - fd = mkstemp(tmp_path); - if (fd < 0) - return -errno; - if (fcntl(fd, F_SETFD, FD_CLOEXEC) < 0) { - ret = -errno; - goto err; - } -#endif + return fd; /* the temp file is created with 0600; open it to the registry world. */ if (fchmod(fd, 0644) < 0) { @@ -319,17 +254,17 @@ static int write_attr_atomic(int dir_fd, const char *dir_path, * Open the directory for @device (creating it if needed) and write @attr=@value * atomically. Shared by the create and update paths. */ -static int write_device_attr(const char *device, const char *attr, - const char *value) +static int write_device_attr(struct libnvme_global_ctx *ctx, const char *device, + const char *attr, const char *value) { __cleanup_free char *dir_path = NULL; int dir_fd, ret; - ret = ensure_device_dir(device); + ret = ensure_device_dir(ctx, device); if (ret) return ret; - dir_path = registry_path(device, NULL); + dir_path = registry_path(ctx, device, NULL); if (!dir_path) return -ENOMEM; @@ -417,11 +352,11 @@ static int delete_dir(const char *path) * The udev REMOVE rule deletes entries with a plain "rm -rf", which is not * atomic; this guarantee therefore covers only library-driven deletes. */ -static int delete_dir_atomic(const char *path) +static int delete_dir_atomic(struct libnvme_global_ctx *ctx, const char *path) { __cleanup_free char *trash = NULL; - if (asprintf(&trash, "%s/.trash.XXXXXX", registry_dir()) < 0) + if (asprintf(&trash, "%s/.trash.XXXXXX", registry_dir(ctx)) < 0) return -ENOMEM; if (!mkdtemp(trash)) return -errno; @@ -465,16 +400,17 @@ int libnvmf_registry_create_instance(struct libnvme_global_ctx *ctx, snprintf(device, sizeof(device), "nvme%d", instance); - final_path = registry_path(device, NULL); + final_path = registry_path(ctx, device, NULL); if (!final_path) return -ENOMEM; /* The registry root must exist before mkdtemp() can run inside it. */ - ret = mkdir_p(registry_dir(), 0755); + ret = libnvmf_mkdir_p(registry_dir(ctx), 0755); if (ret) return ret; - if (asprintf(&tmp_dir, "%s/.%s.tmp.XXXXXX", registry_dir(), device) < 0) + if (asprintf(&tmp_dir, "%s/.%s.tmp.XXXXXX", + registry_dir(ctx), device) < 0) return -ENOMEM; if (!mkdtemp(tmp_dir)) return -errno; @@ -511,7 +447,7 @@ int libnvmf_registry_create_instance(struct libnvme_global_ctx *ctx, * means any pre-existing nvmeN/ is stale, so remove it first: rename() * onto a non-empty directory fails. */ - delete_dir_atomic(final_path); + delete_dir_atomic(ctx, final_path); if (rename(tmp_dir, final_path) < 0) { ret = -errno; goto err; @@ -548,7 +484,7 @@ __libnvme_public int libnvmf_registry_retrieve(struct libnvme_global_ctx *ctx, if (!valid_device(device)) return -EINVAL; - path = registry_path(device, attr); + path = registry_path(ctx, device, attr); if (!path) return -ENOMEM; @@ -592,7 +528,7 @@ __libnvme_public int libnvmf_registry_update(struct libnvme_global_ctx *ctx, if (!device || !valid_device(device)) return -EINVAL; - return write_device_attr(device, attr, value); + return write_device_attr(ctx, device, attr, value); } __libnvme_public int libnvmf_registry_delete(struct libnvme_global_ctx *ctx, @@ -605,11 +541,11 @@ __libnvme_public int libnvmf_registry_delete(struct libnvme_global_ctx *ctx, if (!device || !valid_device(device)) return -EINVAL; - path = registry_path(device, NULL); + path = registry_path(ctx, device, NULL); if (!path) return -ENOMEM; - return delete_dir_atomic(path); + return delete_dir_atomic(ctx, path); } __libnvme_public int libnvmf_registry_device_for_each( @@ -626,7 +562,7 @@ __libnvme_public int libnvmf_registry_device_for_each( if (!callback) return -EINVAL; - d = opendir(registry_dir()); + d = opendir(registry_dir(ctx)); if (!d) { if (errno == ENOENT) return 0; @@ -686,7 +622,7 @@ __libnvme_public int libnvmf_registry_attr_for_each( if (!valid_device(device)) return -EINVAL; - dir_path = registry_path(device, NULL); + dir_path = registry_path(ctx, device, NULL); if (!dir_path) return -ENOMEM; diff --git a/libnvme/src/nvme/util-fabrics.c b/libnvme/src/nvme/util-fabrics.c index 1ed3320047..ca57ba0476 100644 --- a/libnvme/src/nvme/util-fabrics.c +++ b/libnvme/src/nvme/util-fabrics.c @@ -6,6 +6,12 @@ * Authors: Martin Belanger */ +#include +#include +#include +#include +#include +#include #include #include @@ -176,3 +182,76 @@ size_t libnvmf_get_entity_version(char *buffer, size_t bufsz) return num_bytes; } + +/* + * File-access helpers shared by the registry and exclusion-list code. + */ + +int libnvmf_mkdir_p(const char *path, mode_t mode) +{ + char tmp[PATH_MAX]; + size_t len; + char *p; + + len = strlen(path); + if (len >= sizeof(tmp)) + return -ENAMETOOLONG; + memcpy(tmp, path, len + 1); + if (len && tmp[len - 1] == '/') + tmp[len - 1] = '\0'; + + for (p = tmp + 1; *p; p++) { + if (*p != '/') + continue; + *p = '\0'; + if (mkdir(tmp, mode) < 0 && errno != EEXIST) + return -errno; + *p = '/'; + } + if (mkdir(tmp, mode) < 0 && errno != EEXIST) + return -errno; + return 0; +} + +int libnvmf_mkstemp(char *template) +{ + int fd; + + /* + * mkostemp() sets O_CLOEXEC atomically but its glibc declaration is + * gated behind _GNU_SOURCE; fall back to mkstemp() + fcntl() where + * _GNU_SOURCE is not defined (e.g. the musl-style CI build). + */ +#ifdef _GNU_SOURCE + fd = mkostemp(template, O_CLOEXEC); + if (fd < 0) + return -errno; +#else + fd = mkstemp(template); + if (fd < 0) + return -errno; + if (fcntl(fd, F_SETFD, FD_CLOEXEC) < 0) { + int e = -errno; + + close(fd); + unlink(template); + return e; + } +#endif + return fd; +} + +/* + * Used to set validate unit test directory. Takes an environment variable + * (envar) containing the test directory to be used. If envar is defined + * validated to make sure it is confined to /tmp and free of ".." traversal. + * This is to prevent an attacker who can inject it into a privileged + * process must not be able to redirect those to an arbitrary location + */ +const char *libnvmf_validate_test_dir(const char *envar) +{ + const char *env = getenv(envar); + + return (env && !strncmp(env, "/tmp/", 5) && !strstr(env, "..")) ? + env : NULL; +} diff --git a/libnvme/src/nvme/util.c b/libnvme/src/nvme/util.c index 11e9858d25..bd6d55dea8 100644 --- a/libnvme/src/nvme/util.c +++ b/libnvme/src/nvme/util.c @@ -58,6 +58,29 @@ #define ERESTART EAGAIN #endif +/* write() may return a short count; loop until the whole buffer is written. */ +int write_all(int fd, const void *buf, size_t len) +{ + const char *p = buf; + + while (len) { + ssize_t w = write(fd, p, len); + + if (w < 0) { + if (errno == EINTR || errno == EAGAIN) + continue; + return -errno; + } + if (w == 0) { + errno = EIO; + return -EIO; + } + p += w; + len -= w; + } + return 0; +} + /* Source Code Control System, query version of binary with 'what' */ const char sccsid[] = "@(#)libnvme " GIT_VERSION; diff --git a/libnvme/test/registry.c b/libnvme/test/registry.c index 68417a493f..e5a302379a 100644 --- a/libnvme/test/registry.c +++ b/libnvme/test/registry.c @@ -28,35 +28,38 @@ int libnvmf_registry_create_instance(struct libnvme_global_ctx *ctx, int instance, const char *owner); static char tmpdir[256]; +static char regdir[280]; /* /registry -- where entries actually live */ -static void setup_tmpdir(void) +static void setup_tmpdir(struct libnvme_global_ctx *ctx) { /* - * /tmp is required: libnvme confines NVME_REGISTRY_DIR to /tmp, so the + * /tmp is required: libnvme confines the test base dir to /tmp, so the * test directory must live there (not under an arbitrary $TMPDIR). */ snprintf(tmpdir, sizeof(tmpdir), "/tmp/nvme-registry-test-XXXXXX"); assert(mkdtemp(tmpdir) != NULL); - setenv("NVME_REGISTRY_DIR", tmpdir, 1); + snprintf(regdir, sizeof(regdir), "%s/registry", tmpdir); + assert(libnvme_set_test_base_dir(ctx, tmpdir) == 0); } static void cleanup_tmpdir(struct libnvme_global_ctx *ctx) { /* - * Remove any remaining device directories, then the tmpdir itself. + * Remove any remaining device directories, then the dirs themselves. * Best-effort: test isolation matters more than perfect cleanup. */ struct dirent *de; DIR *d; - d = opendir(tmpdir); - if (!d) - return; - while ((de = readdir(d)) != NULL) { - if (de->d_name[0] != '.') - libnvmf_registry_delete(ctx, de->d_name); + d = opendir(regdir); + if (d) { + while ((de = readdir(d)) != NULL) { + if (de->d_name[0] != '.') + libnvmf_registry_delete(ctx, de->d_name); + } + closedir(d); } - closedir(d); + rmdir(regdir); rmdir(tmpdir); } @@ -114,7 +117,7 @@ static bool test_create(struct libnvme_global_ctx *ctx) { struct dirent *de; bool leftover = false; - DIR *d = opendir(tmpdir); + DIR *d = opendir(regdir); if (d) { while ((de = readdir(d)) != NULL) { @@ -526,7 +529,7 @@ int main(int argc, char *argv[]) libnvme_set_logging_file(ctx, stdout); libnvme_set_logging_level(ctx, LIBNVME_LOG_DEBUG_VERBOSE, false, false); - setup_tmpdir(); + setup_tmpdir(ctx); pass &= test_create(ctx); pass &= test_update_and_retrieve(ctx); diff --git a/tests/nvme_registry_test.py b/tests/nvme_registry_test.py index 6159866b49..86c829bf18 100644 --- a/tests/nvme_registry_test.py +++ b/tests/nvme_registry_test.py @@ -7,8 +7,8 @@ # Authors: Martin Belanger """CLI integration tests for the 'nvme registry' plugin. -Tests invoke the nvme binary directly with NVME_REGISTRY_DIR pointing at a -temporary directory, so no real NVMe hardware is needed. +Tests invoke the nvme binary directly with LIBNVME_TEST_BASE_DIR pointing at a +temporary sandbox, so no real NVMe hardware is needed. Usage: python3 nvme_registry_test.py """ @@ -28,17 +28,21 @@ class RegistryCLITest(unittest.TestCase): def setUp(self): - # dir='/tmp' is required: nvme confines NVME_REGISTRY_DIR to /tmp. + # dir='/tmp' is required: nvme confines the test base dir to /tmp. self.tmpdir = tempfile.mkdtemp(prefix='nvme-registry-cli-test-', dir='/tmp') + # The registry lives under /registry within the sandbox. + self.regdir = os.path.join(self.tmpdir, 'registry') self.env = os.environ.copy() - self.env['NVME_REGISTRY_DIR'] = self.tmpdir + self.env['LIBNVME_TEST_BASE_DIR'] = self.tmpdir def tearDown(self): - for entry in os.scandir(self.tmpdir): - if entry.is_dir(follow_symlinks=False): - for attr in os.scandir(entry.path): - os.unlink(attr.path) - os.rmdir(entry.path) + if os.path.isdir(self.regdir): + for entry in os.scandir(self.regdir): + if entry.is_dir(follow_symlinks=False): + for attr in os.scandir(entry.path): + os.unlink(attr.path) + os.rmdir(entry.path) + os.rmdir(self.regdir) os.rmdir(self.tmpdir) def _run(self, *args, expect_fail=False): @@ -56,7 +60,7 @@ def _run(self, *args, expect_fail=False): return result def _populate(self, device, attr, value): - dev_dir = os.path.join(self.tmpdir, device) + dev_dir = os.path.join(self.regdir, device) os.makedirs(dev_dir, exist_ok=True) with open(os.path.join(dev_dir, attr), 'w') as f: f.write(value + '\n') From 9d639c9ad29769cdb9d3249fd83632a12e6ab08e Mon Sep 17 00:00:00 2001 From: Martin Belanger Date: Tue, 30 Jun 2026 17:14:57 -0400 Subject: [PATCH 3/9] libnvme: add the libnvmf_tid transport identity Add libnvmf_tid, the owned, hashable transport-ID that uniquely identifies a full host-to-controller path (an NVMe-oF association). It is the identity the exclusion list matches on, and the basis other fabrics features build their per-connection identity on. Split out from the exclusion-list commit so it can be reviewed on its own. Signed-off-by: Martin Belanger --- libnvme/src/accessors-fabrics.ld | 10 + libnvme/src/fabrics-includes.h.in | 1 + libnvme/src/libnvmf.ld | 17 + libnvme/src/meson.build | 2 + libnvme/src/nvme/accessors-fabrics.c | 76 ++++ libnvme/src/nvme/accessors-fabrics.h | 88 ++++ libnvme/src/nvme/private-fabrics.h | 69 ++- libnvme/src/nvme/tid.c | 381 ++++++++++++++++ libnvme/src/nvme/tid.h | 185 ++++++++ libnvme/src/nvme/util-fabrics.c | 60 ++- libnvme/test/meson.build | 12 + libnvme/test/test-tid.c | 625 +++++++++++++++++++++++++++ 12 files changed, 1504 insertions(+), 22 deletions(-) create mode 100644 libnvme/src/nvme/tid.c create mode 100644 libnvme/src/nvme/tid.h create mode 100644 libnvme/test/test-tid.c diff --git a/libnvme/src/accessors-fabrics.ld b/libnvme/src/accessors-fabrics.ld index f4d7f8444c..7d99dae6f0 100644 --- a/libnvme/src/accessors-fabrics.ld +++ b/libnvme/src/accessors-fabrics.ld @@ -73,6 +73,16 @@ LIBNVMF_ACCESSORS_3 { libnvmf_discovery_args_new; libnvmf_discovery_args_set_lsp; libnvmf_discovery_args_set_max_retries; + libnvmf_tid_free; + libnvmf_tid_get_host_iface; + libnvmf_tid_get_hostnqn; + libnvmf_tid_get_hostid; + libnvmf_tid_get_host_traddr; + libnvmf_tid_get_subsysnqn; + libnvmf_tid_get_traddr; + libnvmf_tid_get_transport; + libnvmf_tid_get_trsvcid; + libnvmf_tid_new; libnvmf_uri_get_fragment; libnvmf_uri_get_host; libnvmf_uri_get_path_segments; diff --git a/libnvme/src/fabrics-includes.h.in b/libnvme/src/fabrics-includes.h.in index 8a49c29305..9b7f76284d 100644 --- a/libnvme/src/fabrics-includes.h.in +++ b/libnvme/src/fabrics-includes.h.in @@ -3,3 +3,4 @@ #include #include #include +#include diff --git a/libnvme/src/libnvmf.ld b/libnvme/src/libnvmf.ld index 657b449442..ff2e7fc87e 100644 --- a/libnvme/src/libnvmf.ld +++ b/libnvme/src/libnvmf.ld @@ -67,6 +67,23 @@ LIBNVMF_3 { libnvmf_sectype_str; libnvmf_set_keyring; libnvmf_subtype_str; + libnvmf_tid_dup; + libnvmf_tid_equal; + libnvmf_tid_is_empty; + libnvmf_tid_from_fields; + libnvmf_tid_get_canonical; + libnvmf_tid_get_hash; + libnvmf_tid_parse; + libnvmf_tid_parse_strict; + libnvmf_tid_set_host_iface; + libnvmf_tid_set_hostnqn; + libnvmf_tid_set_hostid; + libnvmf_tid_set_host_traddr; + libnvmf_tid_set_subsysnqn; + libnvmf_tid_set_traddr; + libnvmf_tid_set_transport; + libnvmf_tid_set_trsvcid; + libnvmf_tid_str; libnvmf_treq_str; libnvmf_trtype_str; libnvmf_update_key; diff --git a/libnvme/src/meson.build b/libnvme/src/meson.build index 33987c66a5..38db2d103b 100644 --- a/libnvme/src/meson.build +++ b/libnvme/src/meson.build @@ -66,6 +66,7 @@ if want_fabrics 'nvme/fabrics.c', 'nvme/nbft.c', 'nvme/registry.c', + 'nvme/tid.c', 'nvme/tree-fabrics.c', 'nvme/util-fabrics.c', ] @@ -76,6 +77,7 @@ if want_fabrics 'nvme/nvme-types-nbft.h', 'nvme/nbft.h', 'nvme/registry.h', + 'nvme/tid.h', ] else sources += [ diff --git a/libnvme/src/nvme/accessors-fabrics.c b/libnvme/src/nvme/accessors-fabrics.c index 9c0ded900e..bdc8405481 100644 --- a/libnvme/src/nvme/accessors-fabrics.c +++ b/libnvme/src/nvme/accessors-fabrics.c @@ -384,6 +384,82 @@ __libnvme_public const char *libnvmf_context_get_tls_key_identity( return p->tls_key_identity; } +/**************************************************************************** + * Accessors for: struct libnvmf_tid + ****************************************************************************/ + +__libnvme_public int libnvmf_tid_new(struct libnvmf_tid **pp) +{ + if (!pp) + return -EINVAL; + *pp = calloc(1, sizeof(struct libnvmf_tid)); + return *pp ? 0 : -ENOMEM; +} + +__libnvme_public void libnvmf_tid_free(struct libnvmf_tid *p) +{ + if (!p) + return; + free(p->transport); + free(p->traddr); + free(p->trsvcid); + free(p->subsysnqn); + free(p->host_traddr); + free(p->host_iface); + free(p->hostnqn); + free(p->hostid); + free(p->_canonical); + free(p->_hash); + free(p->_str); + free(p); +} + +__libnvme_public const char *libnvmf_tid_get_transport( + const struct libnvmf_tid *p) +{ + return p->transport; +} + +__libnvme_public const char *libnvmf_tid_get_traddr(const struct libnvmf_tid *p) +{ + return p->traddr; +} + +__libnvme_public const char *libnvmf_tid_get_trsvcid( + const struct libnvmf_tid *p) +{ + return p->trsvcid; +} + +__libnvme_public const char *libnvmf_tid_get_subsysnqn( + const struct libnvmf_tid *p) +{ + return p->subsysnqn; +} + +__libnvme_public const char *libnvmf_tid_get_host_traddr( + const struct libnvmf_tid *p) +{ + return p->host_traddr; +} + +__libnvme_public const char *libnvmf_tid_get_host_iface( + const struct libnvmf_tid *p) +{ + return p->host_iface; +} + +__libnvme_public const char *libnvmf_tid_get_hostnqn( + const struct libnvmf_tid *p) +{ + return p->hostnqn; +} + +__libnvme_public const char *libnvmf_tid_get_hostid(const struct libnvmf_tid *p) +{ + return p->hostid; +} + /**************************************************************************** * Accessors for: struct libnvmf_discovery_args ****************************************************************************/ diff --git a/libnvme/src/nvme/accessors-fabrics.h b/libnvme/src/nvme/accessors-fabrics.h index 6ca710ded8..114e9b8df1 100644 --- a/libnvme/src/nvme/accessors-fabrics.h +++ b/libnvme/src/nvme/accessors-fabrics.h @@ -30,6 +30,7 @@ /* Forward declarations. These are internal (opaque) structs. */ struct libnvmf_context; +struct libnvmf_tid; struct libnvmf_discovery_args; struct libnvmf_uri; @@ -493,6 +494,93 @@ const char *libnvmf_context_get_tls_key(const struct libnvmf_context *p); const char *libnvmf_context_get_tls_key_identity( const struct libnvmf_context *p); +/**************************************************************************** + * Accessors for: struct libnvmf_tid + ****************************************************************************/ + +/** + * libnvmf_tid_new() - Allocate and initialise a libnvmf_tid object. + * @pp: On success, *pp is set to the newly allocated object. + * + * Allocates a zeroed &struct libnvmf_tid on the heap. + * The caller must release it with libnvmf_tid_free(). + * + * Return: 0 on success, -EINVAL if @pp is NULL, + * -ENOMEM if allocation fails. + */ +int libnvmf_tid_new(struct libnvmf_tid **pp); + +/** + * libnvmf_tid_free() - Release a libnvmf_tid object. + * @p: Object previously returned by libnvmf_tid_new(). + * A NULL pointer is silently ignored. + */ +void libnvmf_tid_free(struct libnvmf_tid *p); + +/** + * libnvmf_tid_get_transport() - Get transport. + * @p: The &struct libnvmf_tid instance to query. + * + * Return: The value of the transport field, or NULL if not set. + */ +const char *libnvmf_tid_get_transport(const struct libnvmf_tid *p); + +/** + * libnvmf_tid_get_traddr() - Get traddr. + * @p: The &struct libnvmf_tid instance to query. + * + * Return: The value of the traddr field, or NULL if not set. + */ +const char *libnvmf_tid_get_traddr(const struct libnvmf_tid *p); + +/** + * libnvmf_tid_get_trsvcid() - Get trsvcid. + * @p: The &struct libnvmf_tid instance to query. + * + * Return: The value of the trsvcid field, or NULL if not set. + */ +const char *libnvmf_tid_get_trsvcid(const struct libnvmf_tid *p); + +/** + * libnvmf_tid_get_subsysnqn() - Get subsysnqn. + * @p: The &struct libnvmf_tid instance to query. + * + * Return: The value of the subsysnqn field, or NULL if not set. + */ +const char *libnvmf_tid_get_subsysnqn(const struct libnvmf_tid *p); + +/** + * libnvmf_tid_get_host_traddr() - Get host_traddr. + * @p: The &struct libnvmf_tid instance to query. + * + * Return: The value of the host_traddr field, or NULL if not set. + */ +const char *libnvmf_tid_get_host_traddr(const struct libnvmf_tid *p); + +/** + * libnvmf_tid_get_host_iface() - Get host_iface. + * @p: The &struct libnvmf_tid instance to query. + * + * Return: The value of the host_iface field, or NULL if not set. + */ +const char *libnvmf_tid_get_host_iface(const struct libnvmf_tid *p); + +/** + * libnvmf_tid_get_hostnqn() - Get hostnqn. + * @p: The &struct libnvmf_tid instance to query. + * + * Return: The value of the hostnqn field, or NULL if not set. + */ +const char *libnvmf_tid_get_hostnqn(const struct libnvmf_tid *p); + +/** + * libnvmf_tid_get_hostid() - Get hostid. + * @p: The &struct libnvmf_tid instance to query. + * + * Return: The value of the hostid field, or NULL if not set. + */ +const char *libnvmf_tid_get_hostid(const struct libnvmf_tid *p); + /**************************************************************************** * Accessors for: struct libnvmf_discovery_args ****************************************************************************/ diff --git a/libnvme/src/nvme/private-fabrics.h b/libnvme/src/nvme/private-fabrics.h index 6bb89795d6..637840be32 100644 --- a/libnvme/src/nvme/private-fabrics.h +++ b/libnvme/src/nvme/private-fabrics.h @@ -69,7 +69,6 @@ struct libnvmf_context { // !generate-accessors:read=generated,write=generated const char *tls_key_identity; // !access:write=custom }; - /** * NVMe-oF private struct definitions. * @@ -79,6 +78,54 @@ struct libnvmf_context { // !generate-accessors:read=generated,write=generated * rest of the fabrics layer. */ +/** + * struct libnvmf_tid - Transport ID: identifies a full path between a host and + * an NVMe-oF controller (an NVMe-oF *association*). + * + * The identity is the NVMe Transport tuple (transport, traddr, trsvcid, and + * the host-side host_traddr / host_iface) plus the subsysnqn and the host's + * hostnqn and hostid. The host identity matters: the same physical machine + * connecting to the same target under a different Host NQN -- or a different + * Host Identifier -- is a different host, hence a different path. + * + * Both hostnqn AND hostid are part of the identity. The NVMe Base + * Specification (revision 2.3, section 6.3 "Connect Command") allows a single + * Host NQN to present multiple Host Identifiers as independent "elements" of a + * host, each a separate association -- so the pair, not the Host NQN alone, is + * the host identity. The Linux kernel agrees: nvmf_ctlr_matches_baseopts() + * compares subsysnqn, host nqn and host id. Linux currently enforces a 1:1 + * Host NQN <-> Host Identifier mapping (nvmf_host_add() rejects a mismatch), so + * the multi-hostid case is unreachable there today, but that is kernel policy + * rather than a spec guarantee; carrying hostid keeps the TID correct anyway. + * + * This is deliberately a separate type from struct libnvme_ctrl_params, not a + * reuse of it. libnvmf_tid is a pure, owned, hashable *identity*: it owns its + * strings, caches derived values (canonical form, hash, string rendering), and + * carries hostnqn/hostid, which libnvme_ctrl_params does not. + * libnvme_ctrl_params is a controller-*creation* parameter bag: borrowed + * pointers, no hashing, and it carries the fabrics tuning config (struct + * libnvme_fabrics_config) that the TID intentionally excludes. Merging them + * would force one role onto the other. + * + * All string fields are owned (strdup'd) by the struct. The leading-underscore + * members cache derived values (canonical form, hash, string rendering), + * recomputed lazily and cleared by any setter. + */ +struct libnvmf_tid { // !generate-accessors !generate-lifecycle + char *transport; // !access:write=custom + char *traddr; // !access:write=custom + char *trsvcid; // !access:write=custom + char *subsysnqn; // !access:write=custom + char *host_traddr; // !access:write=custom + char *host_iface; // !access:write=custom + char *hostnqn; // !access:write=custom + char *hostid; // !access:write=custom + /* cached derived values — recomputed lazily, cleared by any setter */ + char *_canonical; // !access:read=none,write=none + char *_hash; // !access:read=none,write=none + char *_str; // !access:read=none,write=none +}; + struct libnvmf_discovery_args { // !generate-accessors !generate-lifecycle int max_retries; // !default:6 __u8 lsp; // !default:NVMF_LOG_DISC_LSP_NONE @@ -215,18 +262,18 @@ size_t libnvmf_get_entity_name(char *buffer, size_t bufsz); */ size_t libnvmf_get_entity_version(char *buffer, size_t bufsz); -/* - * File-access helpers shared across the fabrics layer (util-fabrics.c). - * - * libnvmf_mkdir_p() - mkdir -p; returns 0 or -errno. - * libnvmf_mkstemp() - mkstemp() with close-on-exec (always); - * returns fd or -errno. - * libnvmf_validate_test_dir() - Ensure test dir override is confined to /tmp - * and has no "..". - */ +/* File-access and misc helpers (util-fabrics.c). */ int libnvmf_mkdir_p(const char *path, mode_t mode); int libnvmf_mkstemp(char *template); -const char *libnvmf_validate_test_dir(const char *envar); +void libnvmf_fsync_dir(const char *path); +bool libnvmf_valid_name(const char *s); +uint64_t libnvmf_fnv1a_64(const void *buf, size_t len); + +/* + * libnvmf_trim() - strip leading/trailing whitespace in place; returns a + * pointer into @s. + */ +char *libnvmf_trim(char *s); /** * libnvmf_registry_create_instance - Write a registry entry for a freshly diff --git a/libnvme/src/nvme/tid.c b/libnvme/src/nvme/tid.c new file mode 100644 index 0000000000..a0bb341b8d --- /dev/null +++ b/libnvme/src/nvme/tid.c @@ -0,0 +1,381 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/* + * This file is part of libnvme. + * Copyright (c) 2026 Dell Technologies Inc. or its subsidiaries. + * + * Authors: Martin Belanger + */ + +#include +#include +#include +#include +#include + +#include "compiler-attributes.h" +#include "nvme/accessors-fabrics.h" +#include "nvme/lib.h" +#include "nvme/tid.h" +#include "private-fabrics.h" + +static void invalidate_cache(struct libnvmf_tid *p) +{ + free(p->_canonical); + p->_canonical = NULL; + free(p->_hash); + p->_hash = NULL; + free(p->_str); + p->_str = NULL; +} + +// Custom setters: strdup the new value and invalidate the derived-value cache + +__libnvme_public void libnvmf_tid_set_transport( + struct libnvmf_tid *p, const char *val) +{ + invalidate_cache(p); + free(p->transport); + p->transport = xstrdup(val); +} + +__libnvme_public void libnvmf_tid_set_traddr( + struct libnvmf_tid *p, const char *val) +{ + invalidate_cache(p); + free(p->traddr); + p->traddr = xstrdup(val); +} + +__libnvme_public void libnvmf_tid_set_trsvcid( + struct libnvmf_tid *p, const char *val) +{ + invalidate_cache(p); + free(p->trsvcid); + p->trsvcid = xstrdup(val); +} + +__libnvme_public void libnvmf_tid_set_subsysnqn( + struct libnvmf_tid *p, const char *val) +{ + invalidate_cache(p); + free(p->subsysnqn); + p->subsysnqn = xstrdup(val); +} + +__libnvme_public void libnvmf_tid_set_host_traddr( + struct libnvmf_tid *p, const char *val) +{ + invalidate_cache(p); + free(p->host_traddr); + p->host_traddr = xstrdup(val); +} + +__libnvme_public void libnvmf_tid_set_host_iface( + struct libnvmf_tid *p, const char *val) +{ + invalidate_cache(p); + free(p->host_iface); + p->host_iface = xstrdup(val); +} + +__libnvme_public void libnvmf_tid_set_hostnqn( + struct libnvmf_tid *p, const char *val) +{ + invalidate_cache(p); + free(p->hostnqn); + p->hostnqn = xstrdup(val); +} + +__libnvme_public void libnvmf_tid_set_hostid( + struct libnvmf_tid *p, const char *val) +{ + invalidate_cache(p); + free(p->hostid); + p->hostid = xstrdup(val); +} + +/* + * Build "key=val;key=val;..." in fixed field order, skipping NULL fields. + * Max canonical length: 2 NQNs (223 chars each) + short fields + keys + + * separators = ~700 chars. CANONICAL_MAX of 1024 is a safe upper bound. + */ +#define CANONICAL_MAX 1024 + +__libnvme_public const char *libnvmf_tid_get_canonical( + const struct libnvmf_tid *tid) +{ + struct libnvmf_tid *p = (struct libnvmf_tid *)tid; + char buf[CANONICAL_MAX]; + int n = 0; + + if (!tid) + return NULL; + + if (p->_canonical) + return p->_canonical; + + /* n stays < CANONICAL_MAX so (CANONICAL_MAX - n) cannot wrap. */ +#define APPEND(key, field) \ + do { \ + if (p->field && n < (int)CANONICAL_MAX) \ + n += snprintf(buf + n, CANONICAL_MAX - n, "%s%s=%s", \ + n ? ";" : "", key, p->field); \ + } while (0) + + APPEND("transport", transport); + APPEND("traddr", traddr); + APPEND("trsvcid", trsvcid); + APPEND("nqn", subsysnqn); + APPEND("host-traddr", host_traddr); + APPEND("host-iface", host_iface); + APPEND("hostnqn", hostnqn); + APPEND("hostid", hostid); + +#undef APPEND + + p->_canonical = strdup(buf); + return p->_canonical; +} + +__libnvme_public const char *libnvmf_tid_get_hash( + const struct libnvmf_tid *tid) +{ + struct libnvmf_tid *p = (struct libnvmf_tid *)tid; + const char *canon; + uint64_t h; + + if (!tid) + return NULL; + + if (p->_hash) + return p->_hash; + + canon = libnvmf_tid_get_canonical(tid); + if (!canon) + return NULL; + + /* + * Truncate the 64-bit FNV-1a value to 48 bits / 12 hex chars. This + * keeps the derived unit name short while keeping collisions negligible + * at realistic scale: for ~200 concurrently-connected controllers the + * birthday-bound collision probability is on the order of 1 in 1e10. + */ + h = libnvmf_fnv1a_64(canon, strlen(canon)) & 0xffffffffffffULL; + if (asprintf(&p->_hash, "%012" PRIx64, h) < 0) { + p->_hash = NULL; + return NULL; + } + return p->_hash; +} + +/* + * Human-readable, log-friendly rendering of a TID: + * "(transport, traddr, trsvcid[, subsysnqn][, host_iface][, host_traddr])". + * Matches nvme-stas's TID string form so the two tools' journals line up. + * hostnqn and hostid are intentionally omitted -- they are rarely the + * distinguishing field and would only add noise. Cached like the other + * derived values. + */ +__libnvme_public const char *libnvmf_tid_str(const struct libnvmf_tid *tid) +{ + struct libnvmf_tid *p = (struct libnvmf_tid *)tid; + char buf[CANONICAL_MAX]; + const char *sep = ""; + int n = 1; + + if (!tid) + return NULL; + + if (p->_str) + return p->_str; + + buf[0] = '('; + +#define APPEND(field) \ + do { \ + if (p->field && p->field[0] && n < (int)CANONICAL_MAX) { \ + n += snprintf(buf + n, CANONICAL_MAX - n, "%s%s", \ + sep, p->field); \ + sep = ", "; \ + } \ + } while (0) + + APPEND(transport); + APPEND(traddr); + APPEND(trsvcid); + APPEND(subsysnqn); + APPEND(host_iface); + APPEND(host_traddr); + if (n < (int)CANONICAL_MAX) + snprintf(buf + n, CANONICAL_MAX - n, ")"); + +#undef APPEND + + p->_str = strdup(buf); + return p->_str; +} + +__libnvme_public struct libnvmf_tid *libnvmf_tid_dup( + const struct libnvmf_tid *tid) +{ + if (!tid) + return NULL; + + return libnvmf_tid_from_fields(tid->transport, tid->traddr, + tid->trsvcid, tid->subsysnqn, + tid->host_traddr, tid->host_iface, + tid->hostnqn, tid->hostid); +} + +__libnvme_public bool libnvmf_tid_equal( + const struct libnvmf_tid *a, const struct libnvmf_tid *b) +{ + if (a == b) + return true; + if (!a || !b) + return false; + + return streq0(a->transport, b->transport) && + streq0(a->traddr, b->traddr) && + streq0(a->trsvcid, b->trsvcid) && + streq0(a->subsysnqn, b->subsysnqn) && + streq0(a->host_traddr, b->host_traddr) && + streq0(a->host_iface, b->host_iface) && + streq0(a->hostnqn, b->hostnqn) && + streq0(a->hostid, b->hostid); +} + +__libnvme_public bool libnvmf_tid_is_empty(const struct libnvmf_tid *tid) +{ + if (!tid) + return true; + + return !tid->transport && !tid->traddr && !tid->trsvcid && + !tid->subsysnqn && !tid->host_traddr && !tid->host_iface && + !tid->hostnqn && !tid->hostid; +} + +__libnvme_public struct libnvmf_tid *libnvmf_tid_from_fields( + const char *transport, const char *traddr, + const char *trsvcid, const char *subsysnqn, + const char *host_traddr, const char *host_iface, + const char *hostnqn, const char *hostid) +{ + struct libnvmf_tid *t; + + if (libnvmf_tid_new(&t) < 0) + return NULL; + + t->transport = xstrdup(transport); + t->traddr = xstrdup(traddr); + t->trsvcid = xstrdup(trsvcid); + t->subsysnqn = xstrdup(subsysnqn); + t->host_traddr = xstrdup(host_traddr); + t->host_iface = xstrdup(host_iface); + t->hostnqn = xstrdup(hostnqn); + t->hostid = xstrdup(hostid); + + return t; +} + +/* + * Shared parser. In strict mode a malformed token -- a non-empty bare token + * (no '='), an empty value, or an unrecognized key -- fails the whole parse + * (returns NULL); in lenient mode such tokens are logged and skipped. Empty + * tokens (from ";;" or a trailing ';') are always benign and skipped. + */ +static struct libnvmf_tid *tid_parse(struct libnvme_global_ctx *ctx, + const char *str, bool strict) +{ + struct libnvmf_tid *t; + char *buf, *tok, *save; + bool bad = false; + + if (!str) + return NULL; + + if (libnvmf_tid_new(&t) < 0) + return NULL; + + buf = strdup(str); + if (!buf) { + libnvmf_tid_free(t); + return NULL; + } + + tok = strtok_r(buf, ";", &save); + while (tok && !bad) { + char *eq = strchr(tok, '='); + + if (!eq) { + if (*libnvmf_trim(tok)) { + libnvme_msg(ctx, LIBNVME_LOG_WARN, + "tid_parse: ignoring \"%s\": missing '='\n", + tok); + bad = strict; + } + } else { + const char *key, *val; + + *eq = '\0'; + key = libnvmf_trim(tok); + val = libnvmf_trim(eq + 1); + + if (!*val) { + libnvme_msg(ctx, LIBNVME_LOG_WARN, + "tid_parse: ignoring empty value for \"%s\"\n", + key); + bad = strict; + } else if (!strcmp(key, "transport")) { + free(t->transport); + t->transport = strdup(val); + } else if (!strcmp(key, "traddr")) { + free(t->traddr); + t->traddr = strdup(val); + } else if (!strcmp(key, "trsvcid")) { + free(t->trsvcid); + t->trsvcid = strdup(val); + } else if (!strcmp(key, "nqn")) { + free(t->subsysnqn); + t->subsysnqn = strdup(val); + } else if (!strcmp(key, "host-traddr")) { + free(t->host_traddr); + t->host_traddr = strdup(val); + } else if (!strcmp(key, "host-iface")) { + free(t->host_iface); + t->host_iface = strdup(val); + } else if (!strcmp(key, "hostnqn")) { + free(t->hostnqn); + t->hostnqn = strdup(val); + } else if (!strcmp(key, "hostid")) { + free(t->hostid); + t->hostid = strdup(val); + } else { + libnvme_msg(ctx, LIBNVME_LOG_WARN, + "tid_parse: ignoring unknown key \"%s\"\n", + key); + bad = strict; + } + } + tok = strtok_r(NULL, ";", &save); + } + + free(buf); + if (bad) { + libnvmf_tid_free(t); + return NULL; + } + return t; +} + +__libnvme_public struct libnvmf_tid *libnvmf_tid_parse( + struct libnvme_global_ctx *ctx, const char *str) +{ + return tid_parse(ctx, str, false); +} + +__libnvme_public struct libnvmf_tid *libnvmf_tid_parse_strict( + struct libnvme_global_ctx *ctx, const char *str) +{ + return tid_parse(ctx, str, true); +} diff --git a/libnvme/src/nvme/tid.h b/libnvme/src/nvme/tid.h new file mode 100644 index 0000000000..2d489116b9 --- /dev/null +++ b/libnvme/src/nvme/tid.h @@ -0,0 +1,185 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +/* + * This file is part of libnvme. + * Copyright (c) 2026 Dell Technologies Inc. or its subsidiaries. + * + * Authors: Martin Belanger + */ +#pragma once + +#include +#include + +struct libnvme_global_ctx; + +/** + * Custom setters for struct libnvmf_tid. + * + * Each setter strdup()s the new value, frees the previous one, and invalidates + * the cached canonical string and hash. NULL is accepted and stored as NULL. + */ +void libnvmf_tid_set_transport(struct libnvmf_tid *p, const char *val); +void libnvmf_tid_set_traddr(struct libnvmf_tid *p, const char *val); +void libnvmf_tid_set_trsvcid(struct libnvmf_tid *p, const char *val); +void libnvmf_tid_set_subsysnqn(struct libnvmf_tid *p, const char *val); +void libnvmf_tid_set_host_traddr(struct libnvmf_tid *p, const char *val); +void libnvmf_tid_set_host_iface(struct libnvmf_tid *p, const char *val); +void libnvmf_tid_set_hostnqn(struct libnvmf_tid *p, const char *val); +void libnvmf_tid_set_hostid(struct libnvmf_tid *p, const char *val); + +/** + * libnvmf_tid_dup() - Allocate a copy of an existing TID. + * @tid: Source TID, or NULL. + * + * Return: Allocated copy, or NULL if @tid is NULL or on allocation failure. + */ +struct libnvmf_tid *libnvmf_tid_dup(const struct libnvmf_tid *tid); + +/** + * libnvmf_tid_equal() - Compare two TIDs for equality. + * @a: First TID, or NULL. + * @b: Second TID, or NULL. + * + * Compares all eight fields with exact string equality. Two NULL TIDs are + * considered equal. + * + * Return: true if all fields match, false otherwise. + */ +bool libnvmf_tid_equal(const struct libnvmf_tid *a, + const struct libnvmf_tid *b); + +/** + * libnvmf_tid_is_empty() - Test whether a TID sets no fields. + * @tid: The transport ID, or NULL. + * + * Return: true if @tid is NULL or every field is unset, false otherwise. + */ +bool libnvmf_tid_is_empty(const struct libnvmf_tid *tid); + +/** + * libnvmf_tid_from_fields() - Allocate a TID from individual field strings. + * @transport: Transport type (e.g. "tcp", "rdma", "fc"). + * @traddr: Transport address. + * @trsvcid: Transport service ID (e.g. "8009"). + * @subsysnqn: Subsystem NQN. + * @host_traddr: Host transport address, or NULL. + * @host_iface: Host interface name, or NULL. + * @hostnqn: Host NQN, or NULL. + * @hostid: Host Identifier, or NULL. + * + * Convenience constructor. NULL fields are stored as NULL. + * + * Return: Allocated TID, or NULL on allocation failure. + */ +struct libnvmf_tid *libnvmf_tid_from_fields(const char *transport, + const char *traddr, + const char *trsvcid, + const char *subsysnqn, + const char *host_traddr, + const char *host_iface, + const char *hostnqn, + const char *hostid); + +/** + * libnvmf_tid_parse() - Allocate a TID from a semicolon-separated key=value + * string. + * @ctx: Global context used only for logging the diagnostics below; may be NULL + * to parse silently. + * @str: Input string, e.g. "transport=tcp;traddr=1.2.3.4;trsvcid=8009". + * + * Recognized keys are the "nvme connect" option names: transport, traddr, + * trsvcid, nqn, host-traddr, host-iface, hostnqn, hostid. (The "nqn", + * "host-traddr", and "host-iface" string keys map onto the struct's + * subsysnqn, host_traddr, and host_iface fields, whose names follow the + * C-identifier convention.) Unknown keys, bare keys (no '='), and empty + * values are logged at WARN level (when @ctx is non-NULL) and skipped. + * Whitespace around keys and values is trimmed. NULL input returns NULL. + * + * Return: Allocated TID, or NULL on allocation failure. + */ +struct libnvmf_tid *libnvmf_tid_parse(struct libnvme_global_ctx *ctx, + const char *str); + +/** + * libnvmf_tid_parse_strict() - Like libnvmf_tid_parse(), but reject malformed + * input. + * @ctx: Global context for logging; may be NULL to parse silently. + * @str: Input string. + * + * Same as libnvmf_tid_parse() except that a malformed token -- a non-empty bare + * token (no '='), an empty value, or an unrecognized key -- fails the whole + * parse instead of being skipped. Empty tokens (from ";;" or a trailing ';') + * are still benign. Useful when an unrecognized key should be treated as an + * error (e.g. a typo in a hand-edited config) rather than silently ignored. + * + * Return: Allocated TID, or NULL on a malformed token or allocation failure. + */ +struct libnvmf_tid *libnvmf_tid_parse_strict(struct libnvme_global_ctx *ctx, + const char *str); + +/** + * libnvmf_tid_get_canonical() - Return the canonical string form of a TID. + * @tid: The transport ID. + * + * Returns a deterministic, fixed-field-order semicolon-separated key=value + * string (e.g. "transport=tcp;traddr=1.2.3.4;trsvcid=8009"). Only non-NULL + * fields are included. The string is lazily computed and cached; it is + * invalidated by any setter call. + * + * Stability is the whole point of this form, because the hash returned by + * libnvmf_tid_get_hash() is computed directly from this string. The fixed + * field order makes the output independent of the order fields were set, so + * the same logical TID always yields the same canonical string and therefore + * the same hash. Conversely, *any* difference in the bytes here -- a field + * present vs. absent, or the same field spelled differently -- changes the + * hash. Two producers (e.g. discoverd and a udev helper) only agree on a + * TID's hash if they build the TID with byte-identical field values. + * + * IMPORTANT -- address text is a known foot-gun here. This function does no + * normalization: it hashes the address exactly as stored. The same endpoint + * can be written several ways that are semantically equal but textually + * different, e.g. a hostname vs. its resolved IP, an IPv4-mapped IPv6 address + * ("::ffff:1.2.3.4") vs. dotted IPv4 ("1.2.3.4"), or a compressed vs. + * fully-expanded IPv6 address. Each spelling produces a different canonical + * string and hash. + * + * This variation comes from *user space*, not the kernel. The nvme fabrics + * driver stores traddr/host_traddr verbatim and reports them back through + * sysfs unchanged; it parses numeric addresses only (inet_pton) and never + * resolves hostnames. So a value read from sysfs matches exactly what was + * written to /dev/nvme-fabrics -- the divergence is strictly between producers + * that spell the same endpoint differently (e.g. a daemon that resolves a + * hostname, or normalizes IPv6) before handing it to the kernel. Callers that + * rely on hashes matching across producers (or across a connect/reconnect) + * must build the TID from the same textual form that is handed to the kernel. + * + * Return: Cached canonical string, or NULL on allocation failure. + */ +const char *libnvmf_tid_get_canonical(const struct libnvmf_tid *tid); + +/** + * libnvmf_tid_get_hash() - Return a stable hash string for a TID. + * @tid: The transport ID. + * + * Returns a 12-character lowercase hex string derived from the FNV-1a 64-bit + * hash (truncated to 48 bits) of the canonical TID string. The hash is lazily + * computed and cached; it is invalidated by any setter call. + * + * Suitable for use as a dictionary key or compact log identifier. + * + * Return: Cached hash string, or NULL on allocation failure. + */ +const char *libnvmf_tid_get_hash(const struct libnvmf_tid *tid); + +/** + * libnvmf_tid_str() - Human-readable string form of a TID, for logging. + * @tid: The transport ID. + * + * Returns "(transport, traddr, trsvcid[, subsysnqn][, host_iface][, host_traddr])" + * — the same rendering nvme-stas uses, so logs from both tools line up. + * hostnqn and hostid are omitted. Lazily computed and cached; invalidated by + * any setter. + * + * Return: Cached string, or NULL if @tid is NULL or on allocation failure. + */ +const char *libnvmf_tid_str(const struct libnvmf_tid *tid); diff --git a/libnvme/src/nvme/util-fabrics.c b/libnvme/src/nvme/util-fabrics.c index ca57ba0476..5859550fe4 100644 --- a/libnvme/src/nvme/util-fabrics.c +++ b/libnvme/src/nvme/util-fabrics.c @@ -6,9 +6,11 @@ * Authors: Martin Belanger */ +#include #include #include #include +#include #include #include #include @@ -25,6 +27,20 @@ #include "compiler-attributes.h" +/* FNV-1a 64-bit over a byte range: fast and dependency-free. */ +uint64_t libnvmf_fnv1a_64(const void *buf, size_t len) +{ + const unsigned char *p = buf; + uint64_t hash = 14695981039346656037ULL; + size_t i; + + for (i = 0; i < len; i++) { + hash ^= p[i]; + hash *= 1099511628211ULL; + } + return hash; +} + __libnvme_public struct nvmf_ext_attr *libnvmf_exat_ptr_next( struct nvmf_ext_attr *p) { @@ -241,17 +257,39 @@ int libnvmf_mkstemp(char *template) return fd; } -/* - * Used to set validate unit test directory. Takes an environment variable - * (envar) containing the test directory to be used. If envar is defined - * validated to make sure it is confined to /tmp and free of ".." traversal. - * This is to prevent an attacker who can inject it into a privileged - * process must not be able to redirect those to an arbitrary location - */ -const char *libnvmf_validate_test_dir(const char *envar) +void libnvmf_fsync_dir(const char *path) +{ + int fd = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + + if (fd >= 0) { + fsync(fd); + close(fd); + } +} + +bool libnvmf_valid_name(const char *s) { - const char *env = getenv(envar); + const char *p; - return (env && !strncmp(env, "/tmp/", 5) && !strstr(env, "..")) ? - env : NULL; + if (!s || !*s) + return false; + for (p = s; *p; p++) { + if ((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '_' || *p == '-') + continue; + return false; + } + return true; +} + +char *libnvmf_trim(char *s) +{ + char *end; + + s += strspn(s, " \t\n\r\v\f"); // trim leading spaces + end = s + strlen(s); + while (end > s && isspace((unsigned char)end[-1])) + end--; + *end = '\0'; + return s; } diff --git a/libnvme/test/meson.build b/libnvme/test/meson.build index 8995413b9e..278126871c 100644 --- a/libnvme/test/meson.build +++ b/libnvme/test/meson.build @@ -141,6 +141,18 @@ tree = executable( test('libnvme - tree', tree) if want_fabrics + test_tid = executable( + 'test-tid', + ['test-tid.c'], + dependencies: [ + config_dep, + ccan_dep, + libnvme_dep, + ], + ) + + test('libnvme - tid', test_tid) + registry = executable( 'test-registry', ['registry.c'], diff --git a/libnvme/test/test-tid.c b/libnvme/test/test-tid.c new file mode 100644 index 0000000000..7c263b688e --- /dev/null +++ b/libnvme/test/test-tid.c @@ -0,0 +1,625 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/* + * This file is part of libnvme. + * Copyright (c) 2026 Dell Technologies Inc. or its subsidiaries. + * + * Authors: Martin Belanger + * + * Unit tests for the libnvmf_tid API — libnvmf_tid_parse() (valid input, + * rejected aliases, garbage tokens, duplicate keys, whitespace), plus equal(), + * dup(), get_canonical(), get_hash(), and setter cache invalidation. + * + * Note: garbage-input tests intentionally trigger error messages on stderr; + * that output is expected and does not indicate a test failure. + */ + +#include +#include +#include +#include + +#include +#include + +static int test_rc; + +#define PASS "[PASS]\n" +#define FAIL "[FAIL]\n" + +#define CHECK(cond, fmt, ...) \ + do { \ + if (cond) { \ + printf(" " fmt " " PASS, ##__VA_ARGS__); \ + } else { \ + printf(" " fmt " " FAIL, ##__VA_ARGS__); \ + test_rc = EXIT_FAILURE; \ + } \ + } while (0) + +/* NULL-safe string equality helper */ +static bool streq(const char *a, const char *b) +{ + if (a == b) + return true; + if (!a || !b) + return false; + return !strcmp(a, b); +} + +/* ------------------------------------------------------------------------- + * NULL input + * ------------------------------------------------------------------------- + */ +static bool test_tid_parse_null(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *t; + bool pass; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_parse_null:\n"); + t = libnvmf_tid_parse(ctx, NULL); + pass = (t == NULL); + CHECK(pass, "NULL input → NULL return"); + + libnvme_free_global_ctx(ctx); + + return pass; +} + +/* ------------------------------------------------------------------------- + * Valid input — all eight fields + * ------------------------------------------------------------------------- + */ +static bool test_tid_parse_valid(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *t; + bool pass = true, p; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_parse_valid:\n"); + + t = libnvmf_tid_parse(ctx, + "transport=tcp;traddr=192.168.1.1;trsvcid=4420;" + "nqn=nqn.test;host-traddr=10.0.0.1;" + "host-iface=eth0;hostnqn=nqn.host;" + "hostid=12345678-1234-1234-1234-123456789abc"); + p = (t != NULL); + CHECK(p, "non-NULL result"); + pass &= p; + if (!t) { + libnvme_free_global_ctx(ctx); + return false; + } + + p = streq(libnvmf_tid_get_transport(t), "tcp"); + CHECK(p, "transport=tcp"); + pass &= p; + + p = streq(libnvmf_tid_get_traddr(t), "192.168.1.1"); + CHECK(p, "traddr=192.168.1.1"); + pass &= p; + + p = streq(libnvmf_tid_get_trsvcid(t), "4420"); + CHECK(p, "trsvcid=4420"); + pass &= p; + + p = streq(libnvmf_tid_get_subsysnqn(t), "nqn.test"); + CHECK(p, "nqn=nqn.test"); + pass &= p; + + p = streq(libnvmf_tid_get_host_traddr(t), "10.0.0.1"); + CHECK(p, "host-traddr=10.0.0.1"); + pass &= p; + + p = streq(libnvmf_tid_get_host_iface(t), "eth0"); + CHECK(p, "host-iface=eth0"); + pass &= p; + + p = streq(libnvmf_tid_get_hostnqn(t), "nqn.host"); + CHECK(p, "hostnqn=nqn.host"); + pass &= p; + + p = streq(libnvmf_tid_get_hostid(t), + "12345678-1234-1234-1234-123456789abc"); + CHECK(p, "hostid set"); + pass &= p; + + libnvmf_tid_free(t); + libnvme_free_global_ctx(ctx); + + return pass; +} + +/* ------------------------------------------------------------------------- + * The string keys are the "nvme connect" option names (nqn, host-traddr, + * host-iface); the C-identifier spellings of the struct fields (subsysnqn, + * host_traddr, host_iface) and any other variant (host_nqn) are not + * recognized and must be ignored. + * ------------------------------------------------------------------------- + */ +static bool test_tid_parse_rejected_aliases(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *t; + bool pass = true, p; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_parse_rejected_aliases:\n"); + printf(" (error messages on stderr below are expected)\n"); + + t = libnvmf_tid_parse(ctx, "transport=rdma;subsysnqn=nqn.alias;" + "host_traddr=5.6.7.8;host_iface=ib0;" + "host_nqn=nqn.hostalias"); + p = (t != NULL); + CHECK(p, "non-NULL result"); + pass &= p; + if (!t) { + libnvme_free_global_ctx(ctx); + return false; + } + + p = libnvmf_tid_get_subsysnqn(t) == NULL; + CHECK(p, "subsysnqn= alias ignored (subsysnqn stays NULL)"); + pass &= p; + + p = libnvmf_tid_get_host_traddr(t) == NULL; + CHECK(p, "host_traddr= alias ignored"); + pass &= p; + + p = libnvmf_tid_get_host_iface(t) == NULL; + CHECK(p, "host_iface= alias ignored"); + pass &= p; + + p = libnvmf_tid_get_hostnqn(t) == NULL; + CHECK(p, "host_nqn= alias ignored"); + pass &= p; + + libnvmf_tid_free(t); + libnvme_free_global_ctx(ctx); + + return pass; +} + +/* ------------------------------------------------------------------------- + * Garbage tokens — bare key (no '='), empty value (key=), unknown key. + * These trigger error messages on stderr; the fields must remain NULL. + * ------------------------------------------------------------------------- + */ +static bool test_tid_parse_garbage(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *t; + bool pass = true, p; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_parse_garbage:\n"); + printf(" (error messages on stderr below are expected)\n"); + + /* Bare key — no '=' at all */ + t = libnvmf_tid_parse(ctx, "barekey;transport=tcp"); + p = t && streq(libnvmf_tid_get_transport(t), "tcp") && + libnvmf_tid_get_traddr(t) == NULL; + CHECK(p, "bare key ignored; valid field still parsed"); + pass &= p; + libnvmf_tid_free(t); + + /* Empty value — key= with nothing after the '=' */ + t = libnvmf_tid_parse(ctx, "transport=;traddr=10.0.0.1"); + p = t && libnvmf_tid_get_transport(t) == NULL && + streq(libnvmf_tid_get_traddr(t), "10.0.0.1"); + CHECK(p, "empty value ignored; field stays NULL"); + pass &= p; + libnvmf_tid_free(t); + + /* Empty value after whitespace trimming — "key= " */ + t = libnvmf_tid_parse(ctx, "transport= ;traddr=10.0.0.2"); + p = t && libnvmf_tid_get_transport(t) == NULL && + streq(libnvmf_tid_get_traddr(t), "10.0.0.2"); + CHECK(p, "whitespace-only value ignored; field stays NULL"); + pass &= p; + libnvmf_tid_free(t); + + /* Unknown key */ + t = libnvmf_tid_parse(ctx, "nosuchkey=foo;transport=tcp"); + p = t && streq(libnvmf_tid_get_transport(t), "tcp"); + CHECK(p, "unknown key ignored; valid field still parsed"); + pass &= p; + libnvmf_tid_free(t); + + /* Double separator ";;" — the empty token between is skipped */ + t = libnvmf_tid_parse(ctx, "transport=tcp;;traddr=10.0.0.3"); + p = t && streq(libnvmf_tid_get_transport(t), "tcp") && + streq(libnvmf_tid_get_traddr(t), "10.0.0.3"); + CHECK(p, "\";;\" empty token skipped; both fields parsed"); + pass &= p; + libnvmf_tid_free(t); + + /* All garbage — should return an empty (non-NULL) TID */ + t = libnvmf_tid_parse(ctx, "garbage;=nokey;unknown=val"); + p = t && + libnvmf_tid_get_transport(t) == NULL && + libnvmf_tid_get_traddr(t) == NULL && + libnvmf_tid_get_subsysnqn(t) == NULL; + CHECK(p, "all-garbage string → empty TID (non-NULL)"); + pass &= p; + libnvmf_tid_free(t); + libnvme_free_global_ctx(ctx); + + return pass; +} + +/* ------------------------------------------------------------------------- + * Duplicate key — last value wins + * ------------------------------------------------------------------------- + */ +static bool test_tid_parse_duplicate_key(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *t; + bool pass; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_parse_duplicate_key:\n"); + + t = libnvmf_tid_parse(ctx, "transport=tcp;transport=rdma"); + pass = t && streq(libnvmf_tid_get_transport(t), "rdma"); + CHECK(pass, "duplicate key → last value wins (\"rdma\")"); + libnvmf_tid_free(t); + libnvme_free_global_ctx(ctx); + + return pass; +} + +/* ------------------------------------------------------------------------- + * Whitespace trimming around keys and values + * ------------------------------------------------------------------------- + */ +static bool test_tid_parse_whitespace(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *t; + bool pass = true, p; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_parse_whitespace:\n"); + + t = libnvmf_tid_parse(ctx, " transport = tcp ; traddr = 1.2.3.4 "); + p = t && streq(libnvmf_tid_get_transport(t), "tcp") && + streq(libnvmf_tid_get_traddr(t), "1.2.3.4"); + CHECK(p, "whitespace around key and value is trimmed"); + pass &= p; + libnvmf_tid_free(t); + libnvme_free_global_ctx(ctx); + + return pass; +} + +/* ------------------------------------------------------------------------- + * libnvmf_tid_equal() — including NULL handling (regression for the NULL crash) + * ------------------------------------------------------------------------- + */ +static bool test_tid_equal(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *a, *b; + bool pass = true, p; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_equal:\n"); + + p = libnvmf_tid_equal(NULL, NULL); + CHECK(p, "equal(NULL, NULL) → true"); + pass &= p; + + a = libnvmf_tid_parse(ctx, + "transport=tcp;traddr=1.2.3.4;trsvcid=4420"); + + p = !libnvmf_tid_equal(a, NULL) && !libnvmf_tid_equal(NULL, a); + CHECK(p, "equal(t, NULL) and equal(NULL, t) → false (no crash)"); + pass &= p; + + p = libnvmf_tid_equal(a, a); + CHECK(p, "equal(t, t) → true"); + pass &= p; + + b = libnvmf_tid_parse(ctx, + "transport=tcp;traddr=1.2.3.4;trsvcid=4420"); + p = libnvmf_tid_equal(a, b); + CHECK(p, "identical TIDs → equal"); + pass &= p; + + libnvmf_tid_set_traddr(b, "5.6.7.8"); + p = !libnvmf_tid_equal(a, b); + CHECK(p, "differing field → not equal"); + pass &= p; + + libnvmf_tid_free(a); + libnvmf_tid_free(b); + libnvme_free_global_ctx(ctx); + return pass; +} + +/* ------------------------------------------------------------------------- + * libnvmf_tid_dup() + * ------------------------------------------------------------------------- + */ +static bool test_tid_dup(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *t, *d; + bool pass = true, p; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_dup:\n"); + + p = (libnvmf_tid_dup(NULL) == NULL); + CHECK(p, "dup(NULL) → NULL"); + pass &= p; + + t = libnvmf_tid_parse(ctx, + "transport=tcp;traddr=1.2.3.4;nqn=nqn.test"); + d = libnvmf_tid_dup(t); + p = d && libnvmf_tid_equal(t, d) && d != t; + CHECK(p, "dup is a distinct but equal copy"); + pass &= p; + + libnvmf_tid_free(t); + libnvmf_tid_free(d); + libnvme_free_global_ctx(ctx); + return pass; +} + +/* ------------------------------------------------------------------------- + * libnvmf_tid_get_canonical() — fixed field order, NULL skipping, caching + * ------------------------------------------------------------------------- + */ +static bool test_tid_canonical(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *t; + const char *c1, *c2; + bool pass = true, p; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_canonical:\n"); + + p = (libnvmf_tid_get_canonical(NULL) == NULL); + CHECK(p, "canonical(NULL) → NULL"); + pass &= p; + + /* Input order differs from canonical order; + * unset fields are skipped. + */ + t = libnvmf_tid_parse(ctx, + "traddr=1.2.3.4;transport=tcp;trsvcid=4420"); + c1 = libnvmf_tid_get_canonical(t); + p = streq(c1, "transport=tcp;traddr=1.2.3.4;trsvcid=4420"); + CHECK(p, "canonical uses fixed field order, skips NULL fields"); + pass &= p; + + c2 = libnvmf_tid_get_canonical(t); + p = (c1 == c2); + CHECK(p, "canonical is cached (same pointer on 2nd call)"); + pass &= p; + + libnvmf_tid_free(t); + libnvme_free_global_ctx(ctx); + return pass; +} + +/* ------------------------------------------------------------------------- + * libnvmf_tid_get_hash() — format, stability, caching + * ------------------------------------------------------------------------- + */ +static bool test_tid_hash(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *a, *b, *c; + const char *h1, *h2; + bool pass = true, p; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_hash:\n"); + + p = (libnvmf_tid_get_hash(NULL) == NULL); + CHECK(p, "hash(NULL) → NULL"); + pass &= p; + + a = libnvmf_tid_parse(ctx, + "transport=tcp;traddr=1.2.3.4;trsvcid=4420"); + h1 = libnvmf_tid_get_hash(a); + p = h1 && strlen(h1) == 12 && strspn(h1, "0123456789abcdef") == 12; + CHECK(p, "hash is 12 lowercase hex chars"); + pass &= p; + + h2 = libnvmf_tid_get_hash(a); + p = (h1 == h2); + CHECK(p, "hash is cached (same pointer on 2nd call)"); + pass &= p; + + b = libnvmf_tid_parse(ctx, + "transport=tcp;traddr=1.2.3.4;trsvcid=4420"); + p = streq(libnvmf_tid_get_hash(a), libnvmf_tid_get_hash(b)); + CHECK(p, "equal TIDs → equal hash"); + pass &= p; + + c = libnvmf_tid_parse(ctx, + "transport=tcp;traddr=9.9.9.9;trsvcid=4420"); + p = !streq(libnvmf_tid_get_hash(a), libnvmf_tid_get_hash(c)); + CHECK(p, "different TIDs → different hash"); + pass &= p; + + libnvmf_tid_free(a); + libnvmf_tid_free(b); + libnvmf_tid_free(c); + libnvme_free_global_ctx(ctx); + return pass; +} + +/* ------------------------------------------------------------------------- + * Setters invalidate the canonical/hash caches + * ------------------------------------------------------------------------- + */ +static bool test_tid_setter_invalidates_cache(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *t; + char before[64], h_before[32]; + const char *after, *h_after; + bool pass = true, p; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_setter_invalidates_cache:\n"); + + t = libnvmf_tid_parse(ctx, "transport=tcp;traddr=1.2.3.4"); + + /* Prime the caches, mutate, then confirm they were rebuilt. */ + snprintf(before, sizeof(before), "%s", libnvmf_tid_get_canonical(t)); + snprintf(h_before, sizeof(h_before), "%s", libnvmf_tid_get_hash(t)); + + libnvmf_tid_set_traddr(t, "5.6.7.8"); + + after = libnvmf_tid_get_canonical(t); + p = after && !streq(before, after) && strstr(after, "traddr=5.6.7.8"); + CHECK(p, "setter invalidates canonical cache"); + pass &= p; + + h_after = libnvmf_tid_get_hash(t); + p = h_after && !streq(h_before, h_after); + CHECK(p, "setter invalidates hash cache"); + pass &= p; + + libnvmf_tid_free(t); + libnvme_free_global_ctx(ctx); + return pass; +} + +/* ------------------------------------------------------------------------- + * main + * ------------------------------------------------------------------------- + */ +/* ------------------------------------------------------------------------- + * libnvmf_tid_parse_strict() — a malformed token fails the whole parse + * ------------------------------------------------------------------------- + */ +static bool test_tid_parse_strict(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *t; + bool pass = true, p; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_parse_strict:\n"); + printf(" (error messages on stderr below are expected)\n"); + + /* Well-formed input still parses. */ + t = libnvmf_tid_parse_strict(ctx, "transport=tcp;traddr=1.2.3.4"); + p = t && streq(libnvmf_tid_get_transport(t), "tcp"); + CHECK(p, "valid input → non-NULL"); + pass &= p; + libnvmf_tid_free(t); + + /* An unknown key fails the whole parse. */ + t = libnvmf_tid_parse_strict(ctx, "transport=tcp;bogus=x"); + p = (t == NULL); + CHECK(p, "unknown key → NULL"); + pass &= p; + + /* The C-identifier spelling (subsysnqn) is not a valid key → NULL. */ + t = libnvmf_tid_parse_strict(ctx, "subsysnqn=nqn.a"); + p = (t == NULL); + CHECK(p, "non-option key 'subsysnqn' → NULL"); + pass &= p; + + /* A non-empty bare token fails. */ + t = libnvmf_tid_parse_strict(ctx, "transport=tcp;garbage"); + p = (t == NULL); + CHECK(p, "bare token → NULL"); + pass &= p; + + /* Empty tokens (";;") remain benign even under strict parsing. */ + t = libnvmf_tid_parse_strict(ctx, "transport=tcp;;traddr=1.2.3.4"); + p = t && streq(libnvmf_tid_get_traddr(t), "1.2.3.4"); + CHECK(p, "\";;\" still benign under strict"); + pass &= p; + libnvmf_tid_free(t); + + libnvme_free_global_ctx(ctx); + + return pass; +} + +/* ------------------------------------------------------------------------- + * libnvmf_tid_is_empty() + * ------------------------------------------------------------------------- + */ +static bool test_tid_is_empty(void) +{ + struct libnvme_global_ctx *ctx; + struct libnvmf_tid *t; + bool pass = true, p; + + ctx = libnvme_create_global_ctx(); + + printf("\ntest_tid_is_empty:\n"); + + p = libnvmf_tid_is_empty(NULL); + CHECK(p, "is_empty(NULL) → true"); + pass &= p; + + t = libnvmf_tid_parse(ctx, ""); + p = libnvmf_tid_is_empty(t); + CHECK(p, "TID with no fields → true"); + pass &= p; + libnvmf_tid_free(t); + + t = libnvmf_tid_parse(ctx, "transport=tcp"); + p = !libnvmf_tid_is_empty(t); + CHECK(p, "TID with a field → false"); + pass &= p; + libnvmf_tid_free(t); + + libnvme_free_global_ctx(ctx); + + return pass; +} + +int main(int argc, char *argv[]) +{ + test_rc = EXIT_SUCCESS; + + test_tid_parse_null(); + test_tid_parse_valid(); + test_tid_parse_rejected_aliases(); + test_tid_parse_strict(); + test_tid_parse_garbage(); + test_tid_parse_duplicate_key(); + test_tid_parse_whitespace(); + test_tid_equal(); + test_tid_is_empty(); + test_tid_dup(); + test_tid_canonical(); + test_tid_hash(); + test_tid_setter_invalidates_cache(); + + if (test_rc == EXIT_SUCCESS) + printf("\nAll tests passed.\n"); + else + printf("\nSOME TESTS FAILED.\n"); + + return test_rc; +} From 044617445327b1a3b7a6b180e3eb744ec4362106 Mon Sep 17 00:00:00 2001 From: Martin Belanger Date: Tue, 30 Jun 2026 17:15:32 -0400 Subject: [PATCH 4/9] libnvme: add the NVMe-oF exclusion list Add a system-wide NVMe-oF exclusion list -- a main /etc/nvme/exclusions.conf plus named drop-in lists under /etc/nvme/exclusions.conf.d/ -- naming controllers that orchestrators must not auto-connect. Enforcement is cooperative; the match is minimal -- only the fields present in an entry are compared. The library API (libnvmf_exclusion_*) is built on a new transport-ID type (libnvmf_tid_*) that canonicalizes a connection's identifying tuple and derives a stable hash from it. An "nvme exclusion" plugin provides create/delete/list/add/remove/edit, and SWIG bindings expose the lists, entries and match query to Python. The exclusion list and the transport ID join the ownership registry as the building blocks that let independent NVMe-oF orchestrators (e.g. nvme-discoverd, nvme-stas) coexist: the registry records who owns a controller, the exclusion list lets an admin keep controllers out of every orchestrator's reach, and the TID gives them all one stable identity for a connection. Signed-off-by: Martin Belanger --- libnvme/libnvme/nvme.i | 148 +++++ libnvme/src/fabrics-includes.h.in | 1 + libnvme/src/libnvmf.ld | 12 + libnvme/src/meson.build | 2 + libnvme/src/nvme/exclusion.c | 1034 +++++++++++++++++++++++++++++ libnvme/src/nvme/exclusion.h | 215 ++++++ libnvme/test/exclusion.c | 469 +++++++++++++ libnvme/test/meson.build | 12 + 8 files changed, 1893 insertions(+) create mode 100644 libnvme/src/nvme/exclusion.c create mode 100644 libnvme/src/nvme/exclusion.h create mode 100644 libnvme/test/exclusion.c diff --git a/libnvme/libnvme/nvme.i b/libnvme/libnvme/nvme.i index 838e01b0d3..203ca635f7 100644 --- a/libnvme/libnvme/nvme.i +++ b/libnvme/libnvme/nvme.i @@ -629,6 +629,95 @@ PyObject *registry_device_attrs(struct libnvme_global_ctx *ctx, } return dict; } + +static void _exclusion_collect_name(const char *name, void *user_data) +{ + PyObject *str = PyUnicode_FromString(name); + + if (str) { + PyList_Append((PyObject *)user_data, str); + Py_DECREF(str); + } +} + +PyObject *exclusion_lists(struct libnvme_global_ctx *ctx) +{ + PyObject *list = PyList_New(0); + int ret; + + if (!list) + return NULL; + ret = libnvmf_exclusion_list_for_each(ctx, _exclusion_collect_name, list); + if (ret < 0) { + Py_DECREF(list); + PyErr_Format(PyExc_OSError, + "exclusion_lists failed: %s", strerror(-ret)); + return NULL; + } + if (PyErr_Occurred()) { + Py_DECREF(list); + return NULL; + } + return list; +} + +static void _exclusion_collect_entry(const char *entry, void *user_data) +{ + PyObject *str = PyUnicode_FromString(entry); + + if (str) { + PyList_Append((PyObject *)user_data, str); + Py_DECREF(str); + } +} + +PyObject *exclusion_entries(struct libnvme_global_ctx *ctx, const char *name) +{ + PyObject *list = PyList_New(0); + int ret; + + if (!list) + return NULL; + ret = libnvmf_exclusion_entry_for_each(ctx, name, + _exclusion_collect_entry, list); + if (ret == -ENOENT) + return list; /* no such list -> empty result */ + if (ret < 0) { + Py_DECREF(list); + PyErr_Format(PyExc_OSError, + "exclusion_entries failed: %s", strerror(-ret)); + return NULL; + } + if (PyErr_Occurred()) { + Py_DECREF(list); + return NULL; + } + return list; +} + +/* + * NOTE: SWIG exposes this to Python as _exclusion_match() -- see the + * "%rename(_exclusion_match)" in the declarations section below. The + * public exclusion_match() is the keyword-default wrapper in %pythoncode, + * which forwards here. + */ +PyObject *exclusion_match(struct libnvme_global_ctx *ctx, + const char *transport, const char *traddr, + const char *trsvcid, const char *subsysnqn, + const char *host_traddr, const char *host_iface, + const char *hostnqn, const char *hostid) +{ + struct libnvmf_tid *tid; + bool matched; + + tid = libnvmf_tid_from_fields(transport, traddr, trsvcid, subsysnqn, + host_traddr, host_iface, hostnqn, hostid); + if (!tid) + return PyErr_NoMemory(); + matched = libnvmf_exclusion_match(ctx, tid); + libnvmf_tid_free(tid); + return PyBool_FromLong(matched); +} %} /* --------- end C implementation block --------- */ /*============================================================================*/ @@ -693,6 +782,26 @@ def registry_entries(ctx): """ for device in registry_devices(ctx): yield device, registry_device_attrs(ctx, device) + +def exclusion_match(ctx, transport=None, traddr=None, trsvcid=None, + subsysnqn=None, host_traddr=None, host_iface=None, + hostnqn=None, hostid=None): + """Return True if any exclusion entry matches the given parameters. + + Thin wrapper over the C libnvmf_exclusion_match(): the connection's + transport ID is built and matched entirely inside libnvme, so the + directory scan, minimal-match and address-equality semantics all live + in one place. + + ctx: nvme.GlobalCtx instance. + transport, traddr, trsvcid, subsysnqn, host_traddr, host_iface, + hostnqn, hostid: connection parameters (all optional, default None). + + Returns: + True if excluded, False otherwise. + """ + return _exclusion_match(ctx, transport, traddr, trsvcid, subsysnqn, + host_traddr, host_iface, hostnqn, hostid) %} /*############################################################################*/ @@ -999,6 +1108,34 @@ PyObject *registry_devices(struct libnvme_global_ctx *ctx); PyObject *registry_device_attrs(struct libnvme_global_ctx *ctx, const char *device); +%exception exclusion_lists { + $action + if (PyErr_Occurred()) SWIG_fail; +} +PyObject *exclusion_lists(struct libnvme_global_ctx *ctx); + +%exception exclusion_entries { + $action + if (PyErr_Occurred()) SWIG_fail; +} +PyObject *exclusion_entries(struct libnvme_global_ctx *ctx, const char *name); + +/* + * Wrap the C exclusion_match() (defined in the %{ %} block above) as + * _exclusion_match(); the public exclusion_match() in %pythoncode wraps that + * to give the connection fields optional keyword defaults. + */ +%rename(_exclusion_match) exclusion_match; +%exception exclusion_match { + $action + if (PyErr_Occurred()) SWIG_fail; +} +PyObject *exclusion_match(struct libnvme_global_ctx *ctx, + const char *transport, const char *traddr, + const char *trsvcid, const char *subsysnqn, + const char *host_traddr, const char *host_iface, + const char *hostnqn, const char *hostid); + %rename(_libnvme_first_host) libnvme_first_host; %rename(_libnvme_next_host) libnvme_next_host; %rename(_libnvme_first_subsystem) libnvme_first_subsystem; @@ -1086,6 +1223,17 @@ struct libnvme_ns *libnvme_ctrl_next_ns(struct libnvme_ctrl *c, struct libnvme_n void dump_config() { libnvme_dump_config($self, STDERR_FILENO); } + %feature("autodoc", "Redirect libnvme's on-disk files (registry, exclusion " + "list, ...) under a /tmp sandbox, for testing.\n" + "\n" + "Args:\n" + " path: Sandbox directory under /tmp (must contain no '..').\n" + "\n" + "Returns:\n" + " 0 on success, or a negative errno if the path is rejected.") set_test_base_dir; + int set_test_base_dir(const char *path) { + return libnvme_set_test_base_dir($self, path); + } } diff --git a/libnvme/src/fabrics-includes.h.in b/libnvme/src/fabrics-includes.h.in index 9b7f76284d..98e9bf5689 100644 --- a/libnvme/src/fabrics-includes.h.in +++ b/libnvme/src/fabrics-includes.h.in @@ -1,5 +1,6 @@ #include #include +#include #include #include #include diff --git a/libnvme/src/libnvmf.ld b/libnvme/src/libnvmf.ld index ff2e7fc87e..a398acbe49 100644 --- a/libnvme/src/libnvmf.ld +++ b/libnvme/src/libnvmf.ld @@ -28,6 +28,18 @@ LIBNVMF_3 { libnvmf_discovery_nbft; libnvmf_eflags_str; libnvmf_exat_ptr_next; + libnvmf_exclusion_add; + libnvmf_exclusion_add_ctrl; + libnvmf_exclusion_add_subsysnqn; + libnvmf_exclusion_create; + libnvmf_exclusion_delete; + libnvmf_exclusion_entry_for_each; + libnvmf_exclusion_entry_valid; + libnvmf_exclusion_list_for_each; + libnvmf_exclusion_match; + libnvmf_exclusion_read; + libnvmf_exclusion_remove; + libnvmf_exclusion_write; libnvmf_export_tls_key; libnvmf_export_tls_key_versioned; libnvmf_free_nbft; diff --git a/libnvme/src/meson.build b/libnvme/src/meson.build index 38db2d103b..dc15b5a406 100644 --- a/libnvme/src/meson.build +++ b/libnvme/src/meson.build @@ -63,6 +63,7 @@ if want_fabrics sources += [ 'nvme/accessors-fabrics.c', 'nvme/crypto.c', + 'nvme/exclusion.c', 'nvme/fabrics.c', 'nvme/nbft.c', 'nvme/registry.c', @@ -73,6 +74,7 @@ if want_fabrics headers += [ 'nvme/accessors-fabrics.h', 'nvme/crypto.h', + 'nvme/exclusion.h', 'nvme/fabrics.h', 'nvme/nvme-types-nbft.h', 'nvme/nbft.h', diff --git a/libnvme/src/nvme/exclusion.c b/libnvme/src/nvme/exclusion.c new file mode 100644 index 0000000000..39fbf14371 --- /dev/null +++ b/libnvme/src/nvme/exclusion.c @@ -0,0 +1,1034 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/* + * This file is part of libnvme. + * Copyright (c) 2026 Dell Technologies Inc. or its subsidiaries. + * + * Authors: Martin Belanger + */ + +/* + * System-wide NVMe-oF exclusion list. + * + * Storage: a hand-edited main file SYSCONFDIR/nvme/exclusions.conf plus managed + * drop-in lists under SYSCONFDIR/nvme/exclusions.conf.d/.conf. Matching + * consults the main file and every drop-in. + * Format: an "[exclusions]" INI section holding one "exclusion = key=val;..." + * line per entry; # lines are comments. Other sections are reserved for + * future use and their content is ignored. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cleanup.h" +#include "compiler-attributes.h" +#include "exclusion.h" +#include "lib.h" +#include "nvme/accessors-fabrics.h" +#include "nvme/tid.h" +#include "private.h" +#include "private-fabrics.h" + +#define EXCL_BASE_DEFAULT SYSCONFDIR "/nvme" +#define EXCL_MAIN_NAME "exclusions.conf" +#define EXCL_DROPIN_NAME "exclusions.conf.d" +#define EXCL_MAIN_PATH EXCL_BASE_DEFAULT "/" EXCL_MAIN_NAME +#define EXCL_DROPIN_PATH EXCL_BASE_DEFAULT "/" EXCL_DROPIN_NAME +#define EXCL_SECTION "exclusions" +#define EXCL_LINE_KEY "exclusion" +#define EXCL_LINE_MAX 4096 +#define EXCL_FILE_MAX (1 * 1024 * 1024) /* 1 MiB cap on one list file */ + +/* Header written whenever a list file is first created (via create or add). */ +#define EXCL_HEADER_FMT \ + "# NVMe-oF exclusion list: %s\n" \ + "# Format: exclusion = key=val;key=val\n" \ + "# Keys: transport, traddr, trsvcid, nqn, host-traddr, host-iface, hostnqn, hostid\n" \ + "\n" \ + "[" EXCL_SECTION "]\n" + +/* + * Path helpers. The normal (production) paths are fixed compile-time literals, + * returned directly. A ctx test sandbox (libnvme_set_test_base_dir()) reroots + * them under a throwaway /tmp directory; the path is built once into a static + * and cached. Caching in a process-wide static is safe even though the + * sandbox is a per-ctx property: it is a single-process test feature, so a + * process is either all-production or all-test and the first call fixes the + * right value for the whole run. Returning fixed paths this way keeps the + * callers free of local path buffers; only excl_path() for a *named* drop-in + * (whose path varies with @name) fills a caller buffer. + */ +static const char *excl_base(struct libnvme_global_ctx *ctx) +{ + return ctx->test_base_dir ? ctx->test_base_dir : EXCL_BASE_DEFAULT; +} + +/* + * Return a fixed exclusion path: the compile-time literal @prod_path in + * production, or /@name under a sandbox (built into @buf and + * remembered in @cache). See the note above on why the static cache is safe. + */ +static const char *excl_fixed_path(struct libnvme_global_ctx *ctx, + const char *prod_path, const char *name, + const char **cache, char *buf, size_t len) +{ + int n; + + if (*cache) + return *cache; + if (!ctx->test_base_dir) { + *cache = prod_path; + } else { + n = snprintf(buf, len, "%s/%s", ctx->test_base_dir, name); + *cache = (n > 0 && (size_t)n < len) ? buf : NULL; + } + return *cache; +} + +/* Directory holding the managed drop-in lists (/exclusions.conf.d). */ +static const char *excl_dropin_dir(struct libnvme_global_ctx *ctx) +{ + static const char *cache; + static char buf[PATH_MAX]; + + return excl_fixed_path(ctx, EXCL_DROPIN_PATH, EXCL_DROPIN_NAME, + &cache, buf, sizeof(buf)); +} + +/* Path of the main hand-edited list (/exclusions.conf). */ +static const char *excl_main_path(struct libnvme_global_ctx *ctx) +{ + static const char *cache; + static char buf[PATH_MAX]; + + return excl_fixed_path(ctx, EXCL_MAIN_PATH, EXCL_MAIN_NAME, + &cache, buf, sizeof(buf)); +} + +/* + * Directory that contains a list's file -- where its atomic-write temp file is + * created and which is fsync'd to make a rename durable. The default list + * (@name == NULL) sits directly under the base dir; a named list is a drop-in. + */ +static const char *excl_dir(struct libnvme_global_ctx *ctx, const char *name) +{ + return name ? excl_dropin_dir(ctx) : excl_base(ctx); +} + +/* + * Path of a list file. @name == NULL is the main hand-edited list; a non-NULL + * name is a managed drop-in (/exclusions.conf.d/.conf). Validating + * the name here (with the shared libnvmf_valid_name) guards every public entry + * point uniformly against unsafe characters and path traversal. The named path + * varies with @name so it is built into @buf; the main path is a fixed literal. + * Returns NULL on an invalid name or truncation. + */ +static const char *excl_path(struct libnvme_global_ctx *ctx, + const char *name, char *buf, size_t len) +{ + int n; + + if (!name) + return excl_main_path(ctx); + + if (!libnvmf_valid_name(name)) + return NULL; + + n = snprintf(buf, len, "%s/%s.conf", excl_dropin_dir(ctx), name); + return (n > 0 && (size_t)n < len) ? buf : NULL; +} + +static int ensure_excl_dir(struct libnvme_global_ctx *ctx, const char *name) +{ + const char *dir = excl_dir(ctx, name); + + if (!dir) + return -ENAMETOOLONG; + return libnvmf_mkdir_p(dir, 0755); +} + +/* Build the atomic-write temp template "/.excl.tmp.XXXXXX" in @tmp. */ +static int excl_tmp(const char *dir, char *tmp, size_t len) +{ + int n = snprintf(tmp, len, "%s/.excl.tmp.XXXXXX", dir); + + return (n > 0 && (size_t)n < len) ? 0 : -ENAMETOOLONG; +} + +static bool addr_equal(const char *entry_val, const char *caller_val, + const char *transport) +{ + if (streq0(transport, "fc")) + return streqcase0(entry_val, caller_val); + + return libnvme_ipaddrs_eq(entry_val, caller_val) || + streq0(entry_val, caller_val); +} + +/* + * Minimal match: only the fields the entry (@e) actually sets are checked + * against the target @tid. traddr/host_traddr use addr_equal() for + * transport-aware address comparison; the rest compare verbatim. + */ +static bool tid_subset_match(const struct libnvmf_tid *e, + const struct libnvmf_tid *tid) +{ + if (e->transport && !streq0(e->transport, tid->transport)) + return false; + if (e->traddr && + !(tid->traddr && + addr_equal(e->traddr, tid->traddr, tid->transport))) + return false; + if (e->trsvcid && !streq0(e->trsvcid, tid->trsvcid)) + return false; + if (e->subsysnqn && !streq0(e->subsysnqn, tid->subsysnqn)) + return false; + if (e->host_traddr && + !(tid->host_traddr && + addr_equal(e->host_traddr, tid->host_traddr, tid->transport))) + return false; + if (e->host_iface && !streq0(e->host_iface, tid->host_iface)) + return false; + if (e->hostnqn && !streq0(e->hostnqn, tid->hostnqn)) + return false; + if (e->hostid && !streq0(e->hostid, tid->hostid)) + return false; + return true; +} + +/* + * Check one entry against a transport ID (minimal match): the entry is parsed + * into a partial TID, and only the fields it sets are compared against @tid. A + * malformed entry (unknown key, bare token, or empty value) or one that sets no + * fields matches nothing -- we never guess. Parsed silently (ctx == NULL): + * matching runs on every connect, so a bad hand-edited entry must stay quiet. + */ +static bool entry_matches(const char *entry, const struct libnvmf_tid *tid) +{ + struct libnvmf_tid *e = libnvmf_tid_parse_strict(NULL, entry); + bool matches; + + if (!e) + return false; + if (libnvmf_tid_is_empty(e)) { + libnvmf_tid_free(e); + return false; + } + + matches = tid_subset_match(e, tid); + libnvmf_tid_free(e); + return matches; +} + +/* + * Validate an entry before writing it: it must parse cleanly (every key known, + * no malformed token) and set at least one field. @ctx is used only to log + * what was wrong; it may be NULL to validate silently. + */ +static bool entry_valid(struct libnvme_global_ctx *ctx, const char *entry) +{ + struct libnvmf_tid *e = libnvmf_tid_parse_strict(ctx, entry); + bool valid = e && !libnvmf_tid_is_empty(e); + + libnvmf_tid_free(e); + return valid; +} + +__libnvme_public bool libnvmf_exclusion_entry_valid(struct libnvme_global_ctx *ctx, + const char *entry) +{ + if (!ctx) + return false; + return entry_valid(ctx, entry); +} + +/* + * Scan one .conf file. For match scan: call entry_matches() on each entry, + * returning true on first match. For iteration: call the callback on each entry. + */ +enum excl_line_type { + EXCL_LINE_IGNORE, /* blank, comment, foreign key or foreign section */ + EXCL_LINE_SECTION, /* well-formed section header; *in_excl updated */ + EXCL_LINE_ENTRY, /* "exclusion =" inside [exclusions]; *val set */ + EXCL_LINE_STRAY, /* "exclusion =" outside [exclusions] */ + EXCL_LINE_JUNK, /* malformed section header */ +}; + +/* + * Classify one line of an exclusion list; the single scan step shared by the + * read, validate, add and remove paths so they can never drift apart. @s is + * a trimmed, mutable scratch copy of the line. @in_excl carries the "inside + * [exclusions]?" state across calls (start it at false); a malformed section + * header clears it, so the entries that follow are ignored rather than + * misattributed -- the fail-safe direction. + */ +static enum excl_line_type classify_line(char *s, bool *in_excl, char **val) +{ + char *eq, *key; + + if (!*s || *s == '#') + return EXCL_LINE_IGNORE; + + if (*s == '[') { + char *end = strchr(s, ']'); + + if (!end) { + *in_excl = false; + return EXCL_LINE_JUNK; + } + *end = '\0'; + *in_excl = !strcmp(libnvmf_trim(s + 1), EXCL_SECTION); + return EXCL_LINE_SECTION; + } + + eq = strchr(s, '='); + if (!eq) + return EXCL_LINE_IGNORE; + *eq = '\0'; + key = libnvmf_trim(s); + if (strcmp(key, EXCL_LINE_KEY)) + return EXCL_LINE_IGNORE; + if (!*in_excl) + return EXCL_LINE_STRAY; + + *val = libnvmf_trim(eq + 1); + return EXCL_LINE_ENTRY; +} + +typedef bool (*scan_fn)(const char *entry, void *ctx); + +static bool scan_conf_file(const char *path, scan_fn fn, void *ctx) +{ + FILE *f; + char line[EXCL_LINE_MAX]; + bool in_excl = false; + bool result = false; + + f = fopen(path, "r"); + if (!f) + return false; + + while (fgets(line, sizeof(line), f)) { + char *s = libnvmf_trim(line); + char *val; + + if (classify_line(s, &in_excl, &val) != EXCL_LINE_ENTRY) + continue; + + if (fn(val, ctx)) { + result = true; + break; + } + } + fclose(f); + return result; +} + +static bool match_entry(const char *entry, void *ctx) +{ + return entry_matches(entry, ctx); +} + +__libnvme_public bool libnvmf_exclusion_match(struct libnvme_global_ctx *ctx, + const struct libnvmf_tid *tid) +{ + const char *dir, *mainp; + DIR *d; + struct dirent *de; + bool found = false; + + if (!ctx || !tid) + return false; + + /* The hand-edited main list first, then each managed drop-in. */ + mainp = excl_main_path(ctx); + if (mainp && scan_conf_file(mainp, match_entry, (void *)tid)) + return true; + + dir = excl_dropin_dir(ctx); + if (!dir) + return false; + + d = opendir(dir); + if (!d) + return false; /* fail-open: directory missing = nothing excluded */ + + while ((de = readdir(d)) && !found) { + char path[PATH_MAX]; + const char *dot = strrchr(de->d_name, '.'); + size_t nlen; + + if (!dot || strcmp(dot, ".conf")) + continue; + + nlen = (size_t)(dot - de->d_name); + if (nlen == 0) + continue; + + if (snprintf(path, sizeof(path), "%s/%s", dir, + de->d_name) >= (int)sizeof(path)) + continue; + found = scan_conf_file(path, match_entry, (void *)tid); + } + closedir(d); + return found; +} + +struct iter_ctx { + void (*callback)(const char *entry, void *user_data); + void *user_data; +}; + +static bool iter_entry(const char *entry, void *ctx) +{ + struct iter_ctx *ic = ctx; + + ic->callback(entry, ic->user_data); + return false; /* never stop early */ +} + +__libnvme_public int libnvmf_exclusion_list_for_each( + struct libnvme_global_ctx *ctx, + void (*callback)(const char *name, void *user_data), + void *user_data) +{ + const char *dir; + DIR *d; + struct dirent *de; + + if (!ctx) + return -EINVAL; + + dir = excl_dropin_dir(ctx); + if (!dir) + return -ENAMETOOLONG; + + d = opendir(dir); + if (!d) { + if (errno == ENOENT) + return 0; + return -errno; + } + + while ((de = readdir(d))) { + char name_buf[NAME_MAX]; + const char *dot; + size_t nlen; + + dot = strrchr(de->d_name, '.'); + if (!dot || strcmp(dot, ".conf")) + continue; + + nlen = (size_t)(dot - de->d_name); + if (nlen == 0 || nlen >= sizeof(name_buf)) + continue; + + memcpy(name_buf, de->d_name, nlen); + name_buf[nlen] = '\0'; + callback(name_buf, user_data); + } + closedir(d); + return 0; +} + +__libnvme_public int libnvmf_exclusion_entry_for_each( + struct libnvme_global_ctx *ctx, + const char *name, + void (*callback)(const char *entry, void *user_data), + void *user_data) +{ + const char *path; + char pathbuf[PATH_MAX]; + struct iter_ctx ic = { .callback = callback, .user_data = user_data }; + + if (!ctx) + return -EINVAL; + + path = excl_path(ctx, name, pathbuf, sizeof(pathbuf)); + if (!path) + return -EINVAL; + + if (access(path, F_OK) < 0) + return -ENOENT; + + scan_conf_file(path, iter_entry, &ic); + return 0; +} + +__libnvme_public int libnvmf_exclusion_create(struct libnvme_global_ctx *ctx, + const char *name) +{ + const char *path; + char pathbuf[PATH_MAX]; + int fd, ret; + + if (!ctx) + return -EINVAL; + + ret = ensure_excl_dir(ctx, name); + if (ret) + return ret; + + path = excl_path(ctx, name, pathbuf, sizeof(pathbuf)); + if (!path) + return -EINVAL; + + fd = open(path, O_CREAT | O_EXCL | O_WRONLY, 0644); + if (fd < 0) + return -errno; + + /* + * Set the mode explicitly: O_CREAT honors the caller's umask, so a tight + * root umask would otherwise yield a non-world-readable list. Exclusion + * lists follow /etc/nvme policy -- readable by all, writable by root. + */ + if (fchmod(fd, 0644) < 0) { + ret = -errno; + close(fd); + unlink(path); + return ret; + } + + /* Write the standard header comment. */ + dprintf(fd, EXCL_HEADER_FMT, name ? name : "default"); + close(fd); + return 0; +} + +__libnvme_public int libnvmf_exclusion_delete(struct libnvme_global_ctx *ctx, + const char *name) +{ + const char *path; + char pathbuf[PATH_MAX]; + + if (!ctx) + return -EINVAL; + + path = excl_path(ctx, name, pathbuf, sizeof(pathbuf)); + if (!path) + return -EINVAL; + + if (unlink(path) < 0) + return -errno; + return 0; +} + +__libnvme_public int libnvmf_exclusion_add(struct libnvme_global_ctx *ctx, + const char *name, const char *entry) +{ + char pathbuf[PATH_MAX], tmp[PATH_MAX], line[EXCL_LINE_MAX]; + const char *path, *dir; + FILE *fin, *fout; + int fd, ret = 0; + + if (!ctx) + return -EINVAL; + if (!entry_valid(ctx, entry)) + return -EINVAL; + + ret = ensure_excl_dir(ctx, name); + if (ret) + return ret; + + path = excl_path(ctx, name, pathbuf, sizeof(pathbuf)); + if (!path) + return -EINVAL; + dir = excl_dir(ctx, name); + if (!dir || excl_tmp(dir, tmp, sizeof(tmp))) + return -ENAMETOOLONG; + + fd = libnvmf_mkstemp(tmp); + if (fd < 0) + return fd; + + /* mkstemp creates 0600; widen to /etc/nvme policy (world-readable). */ + if (fchmod(fd, 0644) < 0) { + ret = -errno; + close(fd); + unlink(tmp); + return ret; + } + + fout = fdopen(fd, "w"); + if (!fout) { + ret = -errno; + close(fd); + unlink(tmp); + return ret; + } + + /* Copy existing content if the file exists. */ + fin = fopen(path, "r"); + if (fin) { + bool in_excl = false, has_section = false; + + while (fgets(line, sizeof(line), fin)) { + char parsebuf[EXCL_LINE_MAX]; + char *val; + + fputs(line, fout); + + /* Classify a scratch copy; "line" must stay intact. */ + strncpy(parsebuf, line, sizeof(parsebuf) - 1); + parsebuf[sizeof(parsebuf) - 1] = '\0'; + classify_line(libnvmf_trim(parsebuf), &in_excl, &val); + has_section |= in_excl; + } + fclose(fin); + + /* + * A hand-made file may lack the [exclusions] header; appending + * the entry bare would leave it outside the section, where the + * readers ignore it. (Re-)open the section before appending -- + * a repeated header is legal INI and merely re-enters it. + */ + if (!has_section || !in_excl) + fprintf(fout, "\n[%s]\n", EXCL_SECTION); + } else { + fprintf(fout, EXCL_HEADER_FMT, name ? name : "default"); + } + + fprintf(fout, "%s = %s\n", EXCL_LINE_KEY, entry); + + if (fflush(fout) != 0 || fsync(fileno(fout)) != 0) { + ret = -errno; + fclose(fout); + unlink(tmp); + return ret; + } + if (fclose(fout) != 0) { + ret = -errno; + unlink(tmp); + return ret; + } + + if (rename(tmp, path) < 0) { + ret = -errno; + unlink(tmp); + return ret; + } + libnvmf_fsync_dir(dir); /* make the rename durable */ + return ret; +} + +/* + * Build an exclusion entry string from a controller's transport parameters. + * It emits the transport-addressing tuple that identifies the path -- + * transport, traddr and subsysnqn unconditionally, trsvcid and host-iface + * when set -- and deliberately omits the host identity (hostnqn/hostid), so an + * exclusion built from a controller applies regardless of which host persona + * is connecting. + */ +static int excl_entry_from_ctrl(libnvme_ctrl_t c, char *buf, size_t len) +{ + int n = 0; + + if (!c->transport || !c->traddr || !c->subsysnqn) + return -EINVAL; + + /* Emit only the fields that are present; never a bare "trsvcid=". */ +#define APPEND(fmt, ...) \ + do { \ + if (n >= 0 && (size_t)n < len) \ + n += snprintf(buf + n, len - n, fmt, ##__VA_ARGS__); \ + } while (0) + + APPEND("transport=%s;traddr=%s", c->transport, c->traddr); + if (c->trsvcid && *c->trsvcid) + APPEND(";trsvcid=%s", c->trsvcid); + APPEND(";nqn=%s", c->subsysnqn); + if (c->host_iface && *c->host_iface) + APPEND(";host-iface=%s", c->host_iface); + +#undef APPEND + + return (n > 0 && (size_t)n < len) ? 0 : -ENAMETOOLONG; +} + +__libnvme_public int libnvmf_exclusion_add_ctrl(struct libnvme_global_ctx *ctx, + const char *name, + struct libnvme_ctrl *c) +{ + char entry[EXCL_LINE_MAX]; + int ret; + + if (!ctx || !c) + return -EINVAL; + + ret = excl_entry_from_ctrl(c, entry, sizeof(entry)); + if (ret) + return ret; + + return libnvmf_exclusion_add(ctx, name, entry); +} + +__libnvme_public int libnvmf_exclusion_add_subsysnqn( + struct libnvme_global_ctx *ctx, const char *name, + const char *subsysnqn) +{ + char entry[EXCL_LINE_MAX]; + int n; + + if (!ctx || !subsysnqn || !*subsysnqn) + return -EINVAL; + + n = snprintf(entry, sizeof(entry), "nqn=%s", subsysnqn); + if (n <= 0 || (size_t)n >= sizeof(entry)) + return -ENAMETOOLONG; + + return libnvmf_exclusion_add(ctx, name, entry); +} + +__libnvme_public int libnvmf_exclusion_remove(struct libnvme_global_ctx *ctx, + const char *name, const char *entry) +{ + char pathbuf[PATH_MAX], tmp[PATH_MAX], line[EXCL_LINE_MAX]; + const char *path, *dir; + bool in_excl = false; + bool removed = false; + FILE *fin, *fout; + int fd, ret = 0; + + if (!ctx) + return -EINVAL; + + path = excl_path(ctx, name, pathbuf, sizeof(pathbuf)); + if (!path) + return -EINVAL; + + fin = fopen(path, "r"); + if (!fin) + return -ENOENT; + dir = excl_dir(ctx, name); + if (!dir || excl_tmp(dir, tmp, sizeof(tmp))) { + fclose(fin); + return -ENAMETOOLONG; + } + + fd = libnvmf_mkstemp(tmp); + if (fd < 0) { + ret = fd; + fclose(fin); + return ret; + } + + /* mkstemp creates 0600; widen to /etc/nvme policy (world-readable). */ + if (fchmod(fd, 0644) < 0) { + ret = -errno; + close(fd); + unlink(tmp); + fclose(fin); + return ret; + } + + fout = fdopen(fd, "w"); + if (!fout) { + ret = -errno; + close(fd); + unlink(tmp); + fclose(fin); + return ret; + } + + while (fgets(line, sizeof(line), fin)) { + char parsebuf[EXCL_LINE_MAX]; + char *val; + + /* Classify a scratch copy; libnvmf_trim() mutates in place and + * would otherwise clobber the trailing newline in "line" before + * it gets passed through to fout. + */ + strncpy(parsebuf, line, sizeof(parsebuf) - 1); + parsebuf[sizeof(parsebuf) - 1] = '\0'; + + /* Everything except the entry being removed passes through. */ + if (classify_line(libnvmf_trim(parsebuf), &in_excl, + &val) == EXCL_LINE_ENTRY && + !removed && !strcmp(val, entry)) + removed = true; /* skip this line */ + else + fputs(line, fout); + } + + fclose(fin); + + if (fflush(fout) != 0 || fsync(fileno(fout)) != 0) { + ret = -errno; + fclose(fout); + unlink(tmp); + return ret; + } + if (fclose(fout) != 0) { + ret = -errno; + unlink(tmp); + return ret; + } + + if (!removed) { + unlink(tmp); + return -ENOENT; + } + + if (rename(tmp, path) < 0) { + ret = -errno; + unlink(tmp); + return ret; + } + libnvmf_fsync_dir(dir); /* make the rename durable */ + return ret; +} + +/* + * FNV-1a 64-bit over a byte range. Used as an opaque optimistic-concurrency + * token: read() hands the caller the hash of the file it saw, write() refuses + * if the file no longer hashes to that value. Never returns 0 -- that value + * is reserved to mean "the list did not exist". + */ +static uint64_t content_hash(const char *buf, size_t len) +{ + uint64_t h = libnvmf_fnv1a_64(buf, len); + + return h ? h : 1; +} + +/* + * Read the whole file at @path into a newly allocated, NUL-terminated buffer. + * On success sets *out (caller frees) and *len (excluding the NUL), returns 0. + * Returns -ENOENT if the file does not exist, or a negative errno otherwise. + */ +static int slurp(const char *path, char **out, size_t *len) +{ + struct stat st; + char *buf; + size_t off = 0; + int fd, ret = 0; + + fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -errno; + if (fstat(fd, &st) < 0) { + ret = -errno; + goto out; + } + if (st.st_size > EXCL_FILE_MAX) { + ret = -EFBIG; + goto out; + } + + buf = malloc(st.st_size + 1); + if (!buf) { + ret = -ENOMEM; + goto out; + } + + while (off < (size_t)st.st_size) { + ssize_t n = read(fd, buf + off, st.st_size - off); + + if (n < 0) { + if (errno == EINTR) + continue; + free(buf); + ret = -errno; + goto out; + } + if (n == 0) + break; + off += n; + } + buf[off] = '\0'; + *out = buf; + *len = off; +out: + close(fd); + return ret; +} + +/* Hash the current on-disk list. Sets *out to 0 when the list is absent. */ +static int hash_file(const char *path, uint64_t *out) +{ + __cleanup_free char *buf = NULL; + size_t len; + int ret; + + ret = slurp(path, &buf, &len); + if (ret == -ENOENT) { + *out = 0; + return 0; + } + if (ret) + return ret; + *out = content_hash(buf, len); + return 0; +} + +/* + * Validate every "exclusion = ..." line in @text. Comments, blank lines and + * non-exclusion keys are ignored. Returns 0 if all entries are valid, -EINVAL + * otherwise. The public write path validates here too -- it cannot trust the + * caller to have pre-checked the buffer. Writing is stricter than reading: + * a malformed section header or an entry outside [exclusions] would be + * silently skipped by the readers (disarming the entry), so reject the buffer + * loudly here instead of letting an editor persist it. + */ +static int validate_conf_buf(struct libnvme_global_ctx *ctx, const char *text) +{ + __cleanup_free char *copy = strdup(text); + char *save = NULL, *line; + bool in_excl = false; + int ret = 0; + + if (!copy) + return -ENOMEM; + + for (line = strtok_r(copy, "\n", &save); line; + line = strtok_r(NULL, "\n", &save)) { + char *s = libnvmf_trim(line), *val; + + switch (classify_line(s, &in_excl, &val)) { + case EXCL_LINE_ENTRY: + if (!entry_valid(ctx, val)) + ret = -EINVAL; + break; + case EXCL_LINE_STRAY: + case EXCL_LINE_JUNK: + ret = -EINVAL; + break; + default: + break; + } + if (ret) + break; + } + return ret; +} + +__libnvme_public int libnvmf_exclusion_read(struct libnvme_global_ctx *ctx, + const char *name, char **text, + uint64_t *version) +{ + char pathbuf[PATH_MAX]; + const char *path; + size_t len; + int ret; + + if (!ctx) + return -EINVAL; + if (!text || !version) + return -EINVAL; + *text = NULL; + *version = 0; + + path = excl_path(ctx, name, pathbuf, sizeof(pathbuf)); + if (!path) + return -EINVAL; + + ret = slurp(path, text, &len); + if (ret == -ENOENT) { + /* A missing list reads as empty so an editor can create it. */ + *text = strdup(""); + return *text ? 0 : -ENOMEM; + } + if (ret) + return ret; + + *version = content_hash(*text, len); + return 0; +} + +__libnvme_public int libnvmf_exclusion_write(struct libnvme_global_ctx *ctx, + const char *name, const char *text, + uint64_t version) +{ + char pathbuf[PATH_MAX], tmp[PATH_MAX]; + const char *path, *dir; + uint64_t cur; + int dir_fd, fd, ret; + + if (!ctx) + return -EINVAL; + if (!text) + return -EINVAL; + + ret = validate_conf_buf(ctx, text); + if (ret) + return ret; + + ret = ensure_excl_dir(ctx, name); + if (ret) + return ret; + + path = excl_path(ctx, name, pathbuf, sizeof(pathbuf)); + if (!path) + return -EINVAL; + + /* + * Optimistic concurrency: serialize only the compare-and-swap window + * (recheck the on-disk version, then rename) under a directory lock. + * The editor ran unlocked, so two editors never block on each other -- + * the second to save sees a changed version and gets -ESTALE rather than + * silently clobbering the first. + */ + dir = excl_dir(ctx, name); + if (!dir || excl_tmp(dir, tmp, sizeof(tmp))) + return -ENAMETOOLONG; + dir_fd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (dir_fd < 0) + return -errno; + if (flock(dir_fd, LOCK_EX) < 0) { + ret = -errno; + goto out; + } + + ret = hash_file(path, &cur); + if (ret) + goto out; + if (cur != version) { + ret = -ESTALE; + goto out; + } + + fd = libnvmf_mkstemp(tmp); + if (fd < 0) { + ret = fd; + goto out; + } + + /* mkstemp creates 0600; widen to /etc/nvme policy (world-readable). */ + if (fchmod(fd, 0644) < 0) { + ret = -errno; + goto err_tmp; + } + ret = write_all(fd, text, strlen(text)); + if (ret) + goto err_tmp; + if (fsync(fd) < 0) { + ret = -errno; + goto err_tmp; + } + close(fd); + + if (rename(tmp, path) < 0) { + ret = -errno; + unlink(tmp); + goto out; + } + libnvmf_fsync_dir(dir); /* make the rename durable */ + ret = 0; + goto out; + +err_tmp: + close(fd); + unlink(tmp); +out: + close(dir_fd); /* releases the flock */ + return ret; +} diff --git a/libnvme/src/nvme/exclusion.h b/libnvme/src/nvme/exclusion.h new file mode 100644 index 0000000000..5a08a71749 --- /dev/null +++ b/libnvme/src/nvme/exclusion.h @@ -0,0 +1,215 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +/* + * This file is part of libnvme. + * Copyright (c) 2026 Dell Technologies Inc. or its subsidiaries. + * + * Authors: Martin Belanger + */ +#pragma once + +#include +#include + +struct libnvmf_tid; +struct libnvme_global_ctx; +struct libnvme_ctrl; + +/** + * DOC: exclusion.h + * + * System-wide NVMe-oF exclusion list. + * + * The exclusion list prevents orchestrators from auto-connecting to + * controllers that a local administrator has explicitly excluded. Each list + * file holds an "[exclusions]" INI section with any number of + * "exclusion = key=val;key=val" entries. + * + * Matching is minimal: only fields present in the exclusion entry are + * checked. A NULL caller parameter for a field that IS present in an entry + * causes that entry not to match — NULL means "this connection has no value + * for this field", not "match any value". + * + * Lists live in /etc/nvme/exclusions.conf (the default list) and + * /etc/nvme/exclusions.conf.d/.conf (named drop-ins). + * + * Exclusion support is only available when fabrics support is enabled. + */ + +/** + * libnvmf_exclusion_match() - check whether a controller matches any exclusion entry. + * @ctx: libnvme global context; must not be NULL + * @tid: Transport ID identifying the controller. All fields are optional; + * NULL means "this connection has no value for this field". + * + * Re-reads the exclusion directory on every call (no caching). + * Entries with unknown keys never match (fail-safe). + * + * Return: true if any exclusion entry matches, false otherwise. + * Returns false (not excluded / fail-open) if the directory cannot be read. + */ +bool libnvmf_exclusion_match(struct libnvme_global_ctx *ctx, + const struct libnvmf_tid *tid); + +/** + * libnvmf_exclusion_entry_valid() - validate an entry string. + * @ctx: libnvme global context; must not be NULL + * @entry: Exclusion entry, e.g. "transport=tcp;traddr=1.2.3.4". + * + * Returns true when every key is known and the entry has at least one + * recognized field; false for unknown/bare keys or an empty/field-less entry. + * Use this to pre-validate a hand-edited entry before writing it. + * + * Return: true if the entry is valid, false otherwise. + */ +bool libnvmf_exclusion_entry_valid(struct libnvme_global_ctx *ctx, + const char *entry); + +/** + * libnvmf_exclusion_list_for_each() - iterate over exclusion list names. + * @ctx: libnvme global context; must not be NULL + * @callback: called for each .conf file; name is basename without .conf suffix + * @user_data: caller context passed to @callback + * + * Return: 0 on success, negative errno on error. + * Returns 0 when the directory does not exist (nothing excluded). + */ +int libnvmf_exclusion_list_for_each( + struct libnvme_global_ctx *ctx, + void (*callback)(const char *name, void *user_data), + void *user_data); + +/** + * libnvmf_exclusion_entry_for_each() - iterate over entries in a named list. + * @ctx: libnvme global context; must not be NULL + * @name: list name (basename without .conf suffix) + * @callback: called with the raw entry string (e.g. "transport=tcp;traddr=...") + * @user_data: caller context passed to @callback + * + * Return: 0 on success, -ENOENT if the list does not exist, negative errno otherwise. + */ +int libnvmf_exclusion_entry_for_each( + struct libnvme_global_ctx *ctx, + const char *name, + void (*callback)(const char *entry, void *user_data), + void *user_data); + +/** + * libnvmf_exclusion_create() - create a new exclusion list. + * @ctx: libnvme global context; must not be NULL + * @name: list name (used as filename: /.conf) + * + * Return: 0 on success, -EEXIST if the list already exists, negative errno otherwise. + */ +int libnvmf_exclusion_create(struct libnvme_global_ctx *ctx, const char *name); + +/** + * libnvmf_exclusion_delete() - delete an exclusion list. + * @ctx: libnvme global context; must not be NULL + * @name: list name + * + * Return: 0 on success, -ENOENT if the list does not exist, negative errno otherwise. + */ +int libnvmf_exclusion_delete(struct libnvme_global_ctx *ctx, const char *name); + +/** + * libnvmf_exclusion_add() - append an entry to a named list (atomic write). + * @ctx: libnvme global context; must not be NULL + * @name: list name + * @entry: semicolon-separated key=value string (e.g. "transport=tcp;traddr=192.168.1.1") + * + * Creates the list if it does not exist. Validates the entry for unknown keys + * before writing; returns -EINVAL if the entry is invalid. + * + * Return: 0 on success, negative errno otherwise. + */ +int libnvmf_exclusion_add(struct libnvme_global_ctx *ctx, + const char *name, const char *entry); + +/** + * libnvmf_exclusion_add_ctrl() - append an entry built from a controller. + * @ctx: libnvme global context; must not be NULL + * @name: list name + * @c: connected controller; the entry's transport, traddr, trsvcid, + * subsysnqn and (when set) host-iface are taken from it + * + * Builds the exclusion entry from the controller's transport parameters inside + * libnvme -- the caller needs no knowledge of the on-disk entry format -- then + * appends it via libnvmf_exclusion_add(). Creates the list if it does not + * exist. + * + * Return: 0 on success, -EINVAL if @ctx or @c is NULL or the controller lacks + * a transport/traddr/subsysnqn, negative errno otherwise. + */ +int libnvmf_exclusion_add_ctrl(struct libnvme_global_ctx *ctx, + const char *name, struct libnvme_ctrl *c); + +/** + * libnvmf_exclusion_add_subsysnqn() - append a subsystem-NQN-only entry. + * @ctx: libnvme global context; must not be NULL + * @name: list name + * @subsysnqn: subsystem NQN to exclude; must not be NULL or empty + * + * Builds an exclusion entry matching every controller of a subsystem (the + * entry constrains only the NQN) inside libnvme -- the caller needs no + * knowledge of the on-disk entry format -- then appends it via + * libnvmf_exclusion_add(). Creates the list if it does not exist. + * + * Return: 0 on success, -EINVAL if @ctx or @subsysnqn is NULL/empty, + * negative errno otherwise. + */ +int libnvmf_exclusion_add_subsysnqn(struct libnvme_global_ctx *ctx, + const char *name, const char *subsysnqn); + +/** + * libnvmf_exclusion_remove() - remove an entry by exact content match (atomic write). + * @ctx: libnvme global context; must not be NULL + * @name: list name + * @entry: entry string to remove, exactly as returned by + * libnvmf_exclusion_entry_for_each() (e.g. "transport=tcp;traddr=192.168.1.1") + * + * Return: 0 on success, -ENOENT if the entry or list does not exist, negative errno otherwise. + */ +int libnvmf_exclusion_remove(struct libnvme_global_ctx *ctx, + const char *name, const char *entry); + +/** + * libnvmf_exclusion_read() - read a list's raw text and a concurrency token. + * @ctx: libnvme global context; must not be NULL + * @name: list name + * @text: on success, set to the file's full contents as a newly allocated, + * NUL-terminated string (caller frees). A missing list reads as an + * empty string so an editor can create it. + * @version: on success, set to an opaque token identifying the contents read. + * 0 means the list did not exist. Pass it back to + * libnvmf_exclusion_write() to detect concurrent modification. + * + * Intended for read-modify-write editors: read the raw text, let a human edit + * it, then write it back. The directory location is entirely libnvme's + * concern -- the caller works only in terms of list names. + * + * Return: 0 on success, -EINVAL for a bad name or NULL out-param, negative + * errno otherwise. + */ +int libnvmf_exclusion_read(struct libnvme_global_ctx *ctx, + const char *name, char **text, uint64_t *version); + +/** + * libnvmf_exclusion_write() - atomically replace a list's contents. + * @ctx: libnvme global context; must not be NULL + * @name: list name + * @text: full replacement contents (every "exclusion = ..." line is + * validated and must sit inside the [exclusions] section; + * invalid entries cause -EINVAL and no write) + * @version: token from a prior libnvmf_exclusion_read(). The write proceeds + * only if the on-disk list still matches @version; otherwise it + * returns -ESTALE and leaves the file untouched, so a concurrent + * editor's changes are never silently overwritten. + * + * The replacement is installed atomically (temp file + rename) with mode 0644. + * + * Return: 0 on success, -ESTALE if the list changed since @version was read, + * -EINVAL for an invalid entry or name, negative errno otherwise. + */ +int libnvmf_exclusion_write(struct libnvme_global_ctx *ctx, + const char *name, const char *text, + uint64_t version); diff --git a/libnvme/test/exclusion.c b/libnvme/test/exclusion.c new file mode 100644 index 0000000000..35f2294bb6 --- /dev/null +++ b/libnvme/test/exclusion.c @@ -0,0 +1,469 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/** + * This file is part of libnvme. + * Copyright (c) 2026 Dell Technologies Inc. or its subsidiaries. + * Authors: Martin Belanger + * + * Unit tests for the NVMe-oF exclusion list (exclusion.c). Covers the file + * mode policy (0644, /etc/nvme convention), the read/write round-trip, and the + * optimistic-concurrency (version token / -ESTALE) save protocol. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +static char tmpdir[256]; + +static void setup_tmpdir(struct libnvme_global_ctx *ctx) +{ + /* libnvme confines the test base dir to /tmp, so it lives there. */ + snprintf(tmpdir, sizeof(tmpdir), "/tmp/nvme-exclusion-test-XXXXXX"); + assert(mkdtemp(tmpdir) != NULL); + assert(libnvme_set_test_base_dir(ctx, tmpdir) == 0); +} + +static void cleanup_tmpdir(struct libnvme_global_ctx *ctx) +{ + char dropin[512]; + + libnvmf_exclusion_delete(ctx, "list"); + libnvmf_exclusion_delete(ctx, "fresh"); + snprintf(dropin, sizeof(dropin), "%s/exclusions.conf.d", tmpdir); + rmdir(dropin); + rmdir(tmpdir); +} + +/* Return the permission bits of a named drop-in list's file, or 0 if absent. */ +static unsigned conf_mode(const char *name) +{ + char path[512]; + struct stat st; + + snprintf(path, sizeof(path), "%s/exclusions.conf.d/%s.conf", + tmpdir, name); + if (stat(path, &st) < 0) + return 0; + return st.st_mode & 07777; +} + +struct counter { + int count; +}; + +static void count_entry(const char *entry, void *user_data) +{ + (void)entry; + ((struct counter *)user_data)->count++; +} + +static int entry_count(struct libnvme_global_ctx *ctx, const char *name) +{ + struct counter c = { 0 }; + + libnvmf_exclusion_entry_for_each(ctx, name, count_entry, &c); + return c.count; +} + +static bool test_mode_policy(struct libnvme_global_ctx *ctx) +{ + bool pass = true; + int ret; + + printf("test_mode_policy:\n"); + + ret = libnvmf_exclusion_create(ctx, "list"); + if (ret || conf_mode("list") != 0644) { + printf(" - create ret=%d mode=%04o (want 0644) [FAIL]\n", + ret, conf_mode("list")); + pass = false; + } else { + printf(" - create -> 0644 [PASS]\n"); + } + + ret = libnvmf_exclusion_add(ctx, "list", "transport=tcp;traddr=1.2.3.4"); + if (ret || conf_mode("list") != 0644) { + printf(" - add ret=%d mode=%04o (want 0644) [FAIL]\n", + ret, conf_mode("list")); + pass = false; + } else { + printf(" - add preserves 0644 [PASS]\n"); + } + + return pass; +} + +static bool test_read_write_roundtrip(struct libnvme_global_ctx *ctx) +{ + char *text = NULL; + uint64_t ver = 0; + bool pass = true; + int ret; + + printf("test_read_write_roundtrip:\n"); + + ret = libnvmf_exclusion_read(ctx, "list", &text, &ver); + if (ret || !text || !ver) { + printf(" - read ret=%d ver=%llu [FAIL]\n", ret, + (unsigned long long)ver); + free(text); + return false; + } + printf(" - read text (%zu bytes), ver=%llu [PASS]\n", + strlen(text), (unsigned long long)ver); + + /* Append an entry to the buffer and write it back with the read version. */ + { + char buf[4096]; + + snprintf(buf, sizeof(buf), "%sexclusion = transport=rdma\n", text); + ret = libnvmf_exclusion_write(ctx, "list", buf, ver); + } + if (ret || conf_mode("list") != 0644) { + printf(" - write ret=%d mode=%04o [FAIL]\n", ret, conf_mode("list")); + pass = false; + } else if (entry_count(ctx, "list") != 2) { + printf(" - expected 2 entries, got %d [FAIL]\n", entry_count(ctx, "list")); + pass = false; + } else { + printf(" - write back (version matched) -> 2 entries, 0644 [PASS]\n"); + } + + free(text); + return pass; +} + +static bool test_stale_rejected(struct libnvme_global_ctx *ctx) +{ + char *text = NULL; + uint64_t ver = 0; + bool pass = true; + int ret; + + printf("test_stale_rejected:\n"); + + ret = libnvmf_exclusion_read(ctx, "list", &text, &ver); + assert(ret == 0); + + /* First write changes the file; @ver is now stale. */ + ret = libnvmf_exclusion_write(ctx, "list", + "[exclusions]\nexclusion = transport=tcp\n", ver); + if (ret) { + printf(" - first write ret=%d [FAIL]\n", ret); + free(text); + return false; + } + + /* Second write with the same (now stale) version must be refused. */ + ret = libnvmf_exclusion_write(ctx, "list", + "[exclusions]\nexclusion = transport=fc\n", ver); + if (ret != -ESTALE) { + printf(" - stale write ret=%d (want -ESTALE=%d) [FAIL]\n", + ret, -ESTALE); + pass = false; + } else { + printf(" - stale version rejected with -ESTALE [PASS]\n"); + } + + free(text); + return pass; +} + +static bool test_invalid_entry_rejected(struct libnvme_global_ctx *ctx) +{ + char *text = NULL; + uint64_t ver = 0; + bool pass = true; + int ret; + + printf("test_invalid_entry_rejected:\n"); + + ret = libnvmf_exclusion_read(ctx, "list", &text, &ver); + assert(ret == 0); + + ret = libnvmf_exclusion_write(ctx, "list", + "[exclusions]\nexclusion = boguskey=x\n", ver); + if (ret != -EINVAL) { + printf(" - invalid write ret=%d (want -EINVAL=%d) [FAIL]\n", + ret, -EINVAL); + pass = false; + } else { + printf(" - invalid entry rejected with -EINVAL [PASS]\n"); + } + + free(text); + return pass; +} + +static bool test_missing_then_create(struct libnvme_global_ctx *ctx) +{ + char *text = NULL; + uint64_t ver = 123; /* must be overwritten to 0 */ + bool pass = true; + int ret; + + printf("test_missing_then_create:\n"); + + ret = libnvmf_exclusion_read(ctx, "fresh", &text, &ver); + if (ret || !text || text[0] != '\0' || ver != 0) { + printf(" - read-missing ret=%d ver=%llu text=%p [FAIL]\n", + ret, (unsigned long long)ver, (void *)text); + free(text); + return false; + } + printf(" - missing list reads as empty, ver=0 [PASS]\n"); + free(text); + + /* Writing with version 0 ("expect absent") creates it at 0644. */ + ret = libnvmf_exclusion_write(ctx, "fresh", + "[exclusions]\nexclusion = transport=tcp\n", 0); + if (ret || conf_mode("fresh") != 0644) { + printf(" - create-via-write ret=%d mode=%04o [FAIL]\n", + ret, conf_mode("fresh")); + pass = false; + } else { + printf(" - write(version=0) creates list at 0644 [PASS]\n"); + } + + return pass; +} + +static void noop_list(const char *name, void *user_data) +{ + (void)name; + (void)user_data; +} + +static void noop_entry(const char *entry, void *user_data) +{ + (void)entry; + (void)user_data; +} + +static bool test_null_args(struct libnvme_global_ctx *ctx) +{ + char *text = NULL; + uint64_t ver = 0; + bool pass = true; + int ret; + + printf("test_null_args:\n"); + + /* NULL ctx is rejected by every public API. */ + if (libnvmf_exclusion_create(NULL, "list") != -EINVAL) { + printf(" - create(NULL ctx) [FAIL]\n"); pass = false; + } + if (libnvmf_exclusion_delete(NULL, "list") != -EINVAL) { + printf(" - delete(NULL ctx) [FAIL]\n"); pass = false; + } + if (libnvmf_exclusion_add(NULL, "list", "transport=tcp") != -EINVAL) { + printf(" - add(NULL ctx) [FAIL]\n"); pass = false; + } + if (libnvmf_exclusion_remove(NULL, "list", "transport=tcp") != -EINVAL) { + printf(" - remove(NULL ctx) [FAIL]\n"); pass = false; + } + if (libnvmf_exclusion_read(NULL, "list", &text, &ver) != -EINVAL) { + printf(" - read(NULL ctx) [FAIL]\n"); pass = false; + } + if (libnvmf_exclusion_write(NULL, "list", "", 0) != -EINVAL) { + printf(" - write(NULL ctx) [FAIL]\n"); pass = false; + } + if (libnvmf_exclusion_list_for_each(NULL, noop_list, NULL) != -EINVAL) { + printf(" - list_for_each(NULL ctx) [FAIL]\n"); pass = false; + } + if (libnvmf_exclusion_entry_for_each(NULL, "list", noop_entry, NULL) != -EINVAL) { + printf(" - entry_for_each(NULL ctx) [FAIL]\n"); pass = false; + } + if (libnvmf_exclusion_match(NULL, NULL) != false) { + printf(" - match(NULL ctx) [FAIL]\n"); pass = false; + } + if (libnvmf_exclusion_entry_valid(NULL, "transport=tcp") != false) { + printf(" - entry_valid(NULL ctx) [FAIL]\n"); pass = false; + } + + /* NULL name selects the main list -- a valid target, not an error. */ + ret = libnvmf_exclusion_read(ctx, NULL, &text, &ver); + if (ret != 0) { + printf(" - read(NULL name) reads main list [FAIL]\n"); + pass = false; + } + free(text); + text = NULL; + ret = libnvmf_exclusion_read(ctx, "list", NULL, &ver); + if (ret != -EINVAL) { + printf(" - read(NULL text) [FAIL]\n"); + pass = false; + } + ret = libnvmf_exclusion_write(ctx, "list", NULL, 0); + if (ret != -EINVAL) { + printf(" - write(NULL text) [FAIL]\n"); + pass = false; + } + + if (pass) + printf(" - NULL arguments rejected [PASS]\n"); + return pass; +} + +/* + * Matching: an entry excludes a controller when every field the entry sets + * matches (minimal / subset match); a difference in any set field, or a + * malformed entry, does not match. + */ +static bool test_match(struct libnvme_global_ctx *ctx) +{ + struct libnvmf_tid *tid; + bool pass = true; + int ret; + + printf("test_match:\n"); + + ret = libnvmf_exclusion_add(ctx, "matchlist", + "transport=tcp;traddr=9.9.9.9;nqn=nqn.a"); + if (ret) { + printf(" - add failed: %d [FAIL]\n", ret); + return false; + } + + /* + * A TID matching all of the entry's fields -- plus an extra trsvcid the + * entry does not constrain -- is excluded (subset match). + */ + tid = libnvmf_tid_from_fields("tcp", "9.9.9.9", "4420", "nqn.a", + NULL, NULL, NULL, NULL); + if (libnvmf_exclusion_match(ctx, tid)) { + printf(" - subset match → excluded [PASS]\n"); + } else { + printf(" - subset match → excluded [FAIL]\n"); + pass = false; + } + libnvmf_tid_free(tid); + + /* A difference in one constrained field (subsysnqn) → not excluded. */ + tid = libnvmf_tid_from_fields("tcp", "9.9.9.9", "4420", "nqn.other", + NULL, NULL, NULL, NULL); + if (!libnvmf_exclusion_match(ctx, tid)) { + printf(" - different subsysnqn → not excluded [PASS]\n"); + } else { + printf(" - different subsysnqn → not excluded [FAIL]\n"); + pass = false; + } + libnvmf_tid_free(tid); + + libnvmf_exclusion_delete(ctx, "matchlist"); + return pass; +} + +/* + * Section semantics: entries count only inside [exclusions]. Writing is + * strict (a stray entry or malformed header is rejected); reading is + * fail-safe (anything outside the section is skipped, foreign sections are + * ignored); add repairs a hand-made file that lacks the header. + */ +static bool test_section_semantics(struct libnvme_global_ctx *ctx) +{ + char path[512]; + bool pass = true; + FILE *f; + int ret; + + printf("test_section_semantics:\n"); + + /* A write with an entry before any section header must be refused. */ + ret = libnvmf_exclusion_write(ctx, "sect", + "exclusion = transport=tcp\n", 0); + if (ret != -EINVAL) { + printf(" - stray entry ret=%d (want -EINVAL) [FAIL]\n", ret); + pass = false; + } else { + printf(" - entry outside [exclusions] rejected [PASS]\n"); + } + + /* A malformed section header must be refused. */ + ret = libnvmf_exclusion_write(ctx, "sect", + "[exclusions\nexclusion = transport=tcp\n", 0); + if (ret != -EINVAL) { + printf(" - malformed header ret=%d (want -EINVAL) [FAIL]\n", ret); + pass = false; + } else { + printf(" - malformed section header rejected [PASS]\n"); + } + + /* A foreign section is reserved for the future: accepted, ignored. */ + ret = libnvmf_exclusion_write(ctx, "sect", + "[exclusions]\n" + "exclusion = transport=tcp;traddr=8.8.8.8\n" + "[future]\n" + "somekey = x\n", 0); + if (ret || entry_count(ctx, "sect") != 1) { + printf(" - foreign section ret=%d count=%d [FAIL]\n", + ret, entry_count(ctx, "sect")); + pass = false; + } else { + printf(" - foreign section ignored, 1 entry counted [PASS]\n"); + } + + /* A hand-made file without the header: entries are ignored... */ + snprintf(path, sizeof(path), "%s/exclusions.conf.d/hand.conf", tmpdir); + f = fopen(path, "w"); + assert(f); + fputs("exclusion = transport=fc\n", f); + fclose(f); + if (entry_count(ctx, "hand") != 0) { + printf(" - sectionless file count=%d (want 0) [FAIL]\n", + entry_count(ctx, "hand")); + pass = false; + } else { + printf(" - sectionless entries skipped on read [PASS]\n"); + } + + /* ...and add repairs the file by opening the section first. */ + ret = libnvmf_exclusion_add(ctx, "hand", "transport=rdma"); + if (ret || entry_count(ctx, "hand") != 1) { + printf(" - add-to-sectionless ret=%d count=%d [FAIL]\n", + ret, entry_count(ctx, "hand")); + pass = false; + } else { + printf(" - add injects [exclusions] before appending [PASS]\n"); + } + + libnvmf_exclusion_delete(ctx, "sect"); + libnvmf_exclusion_delete(ctx, "hand"); + return pass; +} + +int main(void) +{ + struct libnvme_global_ctx *ctx; + bool pass = true; + + ctx = libnvme_create_global_ctx(); + + setup_tmpdir(ctx); + + pass &= test_match(ctx); + pass &= test_mode_policy(ctx); + pass &= test_read_write_roundtrip(ctx); + pass &= test_stale_rejected(ctx); + pass &= test_invalid_entry_rejected(ctx); + pass &= test_missing_then_create(ctx); + pass &= test_section_semantics(ctx); + pass &= test_null_args(ctx); + + cleanup_tmpdir(ctx); + + libnvme_free_global_ctx(ctx); + + fflush(stdout); + exit(pass ? EXIT_SUCCESS : EXIT_FAILURE); +} diff --git a/libnvme/test/meson.build b/libnvme/test/meson.build index 278126871c..844eaaad6a 100644 --- a/libnvme/test/meson.build +++ b/libnvme/test/meson.build @@ -165,6 +165,18 @@ if want_fabrics test('libnvme - registry', registry) + exclusion = executable( + 'test-exclusion', + ['exclusion.c'], + dependencies: [ + config_dep, + ccan_dep, + libnvme_test_dep, + ], + ) + + test('libnvme - exclusion', exclusion) + uriparser = executable( 'test-uriparser', ['uriparser.c'], From 23704ede8c63d5875003b0e8ec407c4de6354943 Mon Sep 17 00:00:00 2001 From: Martin Belanger Date: Tue, 30 Jun 2026 17:15:49 -0400 Subject: [PATCH 5/9] plugins/exclusion: add the nvme exclusion management plugin Add the `nvme exclusion` command group (list/add/remove/create/delete/ edit) as the CRUD front-end to the exclusion API. Split into its own patch so the library and the management tooling land separately. Signed-off-by: Martin Belanger --- meson_options.txt | 4 +- plugins/exclusion/exclusion-nvme.c | 618 +++++++++++++++++++++++++++++ plugins/exclusion/exclusion-nvme.h | 29 ++ plugins/meson.build | 4 + 4 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 plugins/exclusion/exclusion-nvme.c create mode 100644 plugins/exclusion/exclusion-nvme.h diff --git a/meson_options.txt b/meson_options.txt index 7549b115c1..aa3f528bc5 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -176,8 +176,8 @@ option( 'plugins', type : 'array', choices: ['amzn', 'dapustor', 'dell', 'dera', - 'fdp', 'feat', 'huawei', 'ibm', 'innogrit', 'inspur', 'intel', - 'lm', 'mangoboost', 'memblaze', 'micron', 'nbft', + 'exclusion', 'fdp', 'feat', 'huawei', 'ibm', 'innogrit', 'inspur', + 'intel', 'lm', 'mangoboost', 'memblaze', 'micron', 'nbft', 'netapp', 'nvidia', 'ocp', 'registry', 'sandisk', 'scaleflux', 'seagate', 'sed', 'shannon', 'solidigm', 'ssstc', 'toshiba', 'transcend', 'virtium', 'wdc', 'ymtc', 'zns'], diff --git a/plugins/exclusion/exclusion-nvme.c b/plugins/exclusion/exclusion-nvme.c new file mode 100644 index 0000000000..dce4aa95ec --- /dev/null +++ b/plugins/exclusion/exclusion-nvme.c @@ -0,0 +1,618 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * This file is part of nvme-cli. + * Copyright (c) 2026 Dell Technologies Inc. or its subsidiaries. + * + * Authors: Martin Belanger + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "common.h" +#include "nvme.h" +#include "util/cleanup.h" + +#define CREATE_CMD +#include "exclusion-nvme.h" + +/* + * Mutating exclusion commands write the root-owned exclusion lists under + * /etc/nvme. Fail early with a clear message rather than letting the write + * fail later with a bare -EACCES. + */ +static int require_root(void) +{ + if (geteuid() != 0) { + fprintf(stderr, "this command requires root privileges (try sudo)\n"); + return -EPERM; + } + return 0; +} + +static int excl_create(int argc, char **argv, struct command *acmd, + struct plugin *plugin) +{ + const char *desc = "Create a new NVMeoF exclusion list."; + const char *name_help = "exclusion list name"; + __cleanup_nvme_global_ctx struct libnvme_global_ctx *ctx = NULL; + int ret; + + struct config { + char *name; + } cfg = { 0 }; + + NVME_ARGS(opts, + OPT_STRING("name", 'N', "NAME", &cfg.name, name_help)); + + ret = argconfig_parse(argc, argv, desc, opts); + if (ret) + return ret; + + if (!cfg.name) { + fprintf(stderr, "--name required\n"); + return -EINVAL; + } + + ret = require_root(); + if (ret) + return ret; + + ctx = libnvme_create_global_ctx(); + ret = libnvmf_exclusion_create(ctx, cfg.name); + if (ret == -EEXIST) + fprintf(stderr, "exclusion list '%s' already exists\n", cfg.name); + else if (ret) + fprintf(stderr, "create failed: %s\n", libnvme_strerror(-ret)); + return ret; +} + +static int excl_delete(int argc, char **argv, struct command *acmd, + struct plugin *plugin) +{ + const char *desc = "Delete an NVMeoF exclusion list entirely."; + const char *name_help = "exclusion list name"; + __cleanup_nvme_global_ctx struct libnvme_global_ctx *ctx = NULL; + int ret; + + struct config { + char *name; + } cfg = { 0 }; + + NVME_ARGS(opts, + OPT_STRING("name", 'N', "NAME", &cfg.name, name_help)); + + ret = argconfig_parse(argc, argv, desc, opts); + if (ret) + return ret; + + if (!cfg.name) { + fprintf(stderr, "--name required\n"); + return -EINVAL; + } + + ret = require_root(); + if (ret) + return ret; + + ctx = libnvme_create_global_ctx(); + ret = libnvmf_exclusion_delete(ctx, cfg.name); + if (ret == -ENOENT) + fprintf(stderr, "exclusion list '%s' not found\n", cfg.name); + else if (ret) + fprintf(stderr, "delete failed: %s\n", libnvme_strerror(-ret)); + return ret; +} + +static void print_list_name(const char *name, void *user_data __attribute__((unused))) +{ + printf("%s\n", name); +} + +static void print_entry(const char *entry, void *user_data __attribute__((unused))) +{ + printf(" %s\n", entry); +} + +static int excl_list(int argc, char **argv, struct command *acmd, + struct plugin *plugin) +{ + const char *desc = "List exclusion lists, or entries within a named list."; + const char *name_help = "exclusion list name (omit to list all lists)"; + __cleanup_nvme_global_ctx struct libnvme_global_ctx *ctx = NULL; + int ret; + + struct config { + char *name; + } cfg = { 0 }; + + NVME_ARGS(opts, + OPT_STRING("name", 'N', "NAME", &cfg.name, name_help)); + + ret = argconfig_parse(argc, argv, desc, opts); + if (ret) + return ret; + + ctx = libnvme_create_global_ctx(); + + if (!cfg.name) { + ret = libnvmf_exclusion_list_for_each(ctx, print_list_name, NULL); + if (ret) + fprintf(stderr, "list failed: %s\n", libnvme_strerror(-ret)); + return ret; + } + + ret = libnvmf_exclusion_entry_for_each(ctx, cfg.name, print_entry, NULL); + if (ret == -ENOENT) + fprintf(stderr, "exclusion list '%s' not found\n", cfg.name); + else if (ret) + fprintf(stderr, "list failed: %s\n", libnvme_strerror(-ret)); + return ret; +} + +static int excl_add(int argc, char **argv, struct command *acmd, + struct plugin *plugin) +{ + const char *desc = "Add an entry to an NVMeoF exclusion list."; + const char *name_help = "exclusion list name"; + const char *entry_help = "semicolon-separated key=value entry " + "(e.g. \"transport=tcp;traddr=192.168.1.1\")"; + __cleanup_nvme_global_ctx struct libnvme_global_ctx *ctx = NULL; + int ret; + + struct config { + char *name; + char *entry; + } cfg = { 0 }; + + NVME_ARGS(opts, + OPT_STRING("name", 'N', "NAME", &cfg.name, name_help), + OPT_STRING("entry", 'e', "ENTRY", &cfg.entry, entry_help)); + + ret = argconfig_parse(argc, argv, desc, opts); + if (ret) + return ret; + + if (!cfg.name || !cfg.entry) { + fprintf(stderr, "--name and --entry required\n"); + return -EINVAL; + } + + ret = require_root(); + if (ret) + return ret; + + ctx = libnvme_create_global_ctx(); + ret = libnvmf_exclusion_add(ctx, cfg.name, cfg.entry); + if (ret == -EINVAL) + fprintf(stderr, "invalid entry (unknown key): %s\n", cfg.entry); + else if (ret) + fprintf(stderr, "add failed: %s\n", libnvme_strerror(-ret)); + return ret; +} + +struct entry_collection { + char **entries; + size_t count, cap; +}; + +static void collect_entry(const char *entry, void *user_data) +{ + struct entry_collection *ec = user_data; + + if (ec->count == ec->cap) { + size_t newcap = ec->cap ? ec->cap * 2 : 16; + char **newarr = realloc(ec->entries, newcap * sizeof(*newarr)); + + if (!newarr) + return; + ec->entries = newarr; + ec->cap = newcap; + } + ec->entries[ec->count] = strdup(entry); + if (ec->entries[ec->count]) + ec->count++; +} + +static int cmp_entry(const void *a, const void *b) +{ + return strcmp(*(const char * const *)a, *(const char * const *)b); +} + +static int excl_remove(int argc, char **argv, struct command *acmd, + struct plugin *plugin) +{ + const char *desc = "Interactively remove an entry from an NVMeoF exclusion list."; + const char *name_help = "exclusion list name"; + __cleanup_nvme_global_ctx struct libnvme_global_ctx *ctx = NULL; + struct entry_collection ec = { 0 }; + char answer[32]; + size_t i, choice; + int ret; + + struct config { + char *name; + } cfg = { 0 }; + + NVME_ARGS(opts, + OPT_STRING("name", 'N', "NAME", &cfg.name, name_help)); + + ret = argconfig_parse(argc, argv, desc, opts); + if (ret) + return ret; + + if (!cfg.name) { + fprintf(stderr, "--name required\n"); + return -EINVAL; + } + + ret = require_root(); + if (ret) + return ret; + + ctx = libnvme_create_global_ctx(); + ret = libnvmf_exclusion_entry_for_each(ctx, cfg.name, collect_entry, &ec); + if (ret) { + if (ret == -ENOENT) + fprintf(stderr, "exclusion list '%s' not found\n", cfg.name); + else + fprintf(stderr, "list failed: %s\n", libnvme_strerror(-ret)); + return ret; + } + + if (ec.count == 0) { + printf("exclusion list '%s' has no entries\n", cfg.name); + free(ec.entries); + return 0; + } + + qsort(ec.entries, ec.count, sizeof(*ec.entries), cmp_entry); + + for (i = 0; i < ec.count; i++) + printf("%3zu. %s\n", i + 1, ec.entries[i]); + + printf("Which entry do you want to delete? [1-%zu, or empty to cancel] ", + ec.count); + fflush(stdout); + + if (!fgets(answer, sizeof(answer), stdin) || + sscanf(answer, "%zu", &choice) != 1 || + choice < 1 || choice > ec.count) { + printf("cancelled\n"); + ret = 0; + goto out; + } + + ret = libnvmf_exclusion_remove(ctx, cfg.name, ec.entries[choice - 1]); + if (ret) + fprintf(stderr, "remove failed: %s\n", libnvme_strerror(-ret)); + +out: + for (i = 0; i < ec.count; i++) + free(ec.entries[i]); + free(ec.entries); + return ret; +} + +static int run_editor(const char *path) +{ + const char *editor = getenv("EDITOR"); + pid_t pid; + int status; + + if (!editor || !*editor) + editor = "vi"; + + pid = fork(); + if (pid < 0) + return -errno; + if (pid == 0) { + char cmd[PATH_MAX + 64]; + + /* + * Pass the path as $1 and reference it in the command so the + * editor actually opens the file, while still allowing $EDITOR + * to carry arguments (word-split by the shell). + */ + snprintf(cmd, sizeof(cmd), "%s \"$1\"", editor); + execl("/bin/sh", "sh", "-c", cmd, "sh", path, (char *)NULL); + _exit(127); + } + if (waitpid(pid, &status, 0) < 0) + return -errno; + return WIFEXITED(status) && WEXITSTATUS(status) == 0 ? 0 : -EIO; +} + +/* + * Validate a file: every "exclusion = ..." line must be a valid entry and + * must sit inside the [exclusions] section. Mirrors libnvme's write-side + * validation but reports each offense with its line number. + * Returns 0 if the file is valid, -EINVAL and prints errors otherwise. + */ +static int validate_conf_file(struct libnvme_global_ctx *ctx, const char *path) +{ + FILE *f; + char line[4096]; + unsigned lineno = 0; + bool in_excl = false; + int errors = 0; + + f = fopen(path, "r"); + if (!f) + return -errno; + + while (fgets(line, sizeof(line), f)) { + char *s, *eq, *key, *val; + + lineno++; + s = line; + while (*s == ' ' || *s == '\t') + s++; + if (!*s || *s == '#' || *s == '\n') + continue; + + if (*s == '[') { + char *end = strchr(s, ']'); + + if (!end) { + fprintf(stderr, + "line %u: malformed section header\n", + lineno); + errors++; + in_excl = false; + continue; + } + *end = '\0'; + s++; + while (*s == ' ' || *s == '\t') + s++; + while (end > s && (end[-1] == ' ' || end[-1] == '\t')) + *--end = '\0'; + in_excl = !strcmp(s, "exclusions"); + continue; + } + + eq = strchr(s, '='); + if (!eq) + continue; + *eq = '\0'; + key = s; + while (*key == ' ' || *key == '\t') + key++; + char *ke = key + strlen(key); + while (ke > key && (ke[-1] == ' ' || ke[-1] == '\t')) + ke--; + *ke = '\0'; + + if (strcmp(key, "exclusion")) + continue; + + if (!in_excl) { + fprintf(stderr, + "line %u: entry outside the [exclusions] section\n", + lineno); + errors++; + continue; + } + + val = eq + 1; + while (*val == ' ' || *val == '\t') + val++; + char *ve = val + strlen(val); + while (ve > val && (ve[-1] == '\n' || ve[-1] == ' ' || ve[-1] == '\t')) + ve--; + *ve = '\0'; + + /* Pure check — no filesystem side effects. */ + if (!libnvmf_exclusion_entry_valid(ctx, val)) { + fprintf(stderr, "line %u: invalid entry: %s\n", + lineno, val); + errors++; + } + } + fclose(f); + return errors ? -EINVAL : 0; +} + +/* + * Read a whole file into a newly allocated, NUL-terminated string (caller + * frees). Returns NULL on error (errno set). + */ +static char *read_file(const char *path) +{ + FILE *f = fopen(path, "r"); + long sz; + char *buf; + size_t n; + + if (!f) + return NULL; + if (fseek(f, 0, SEEK_END) < 0) { + fclose(f); + return NULL; + } + sz = ftell(f); + if (sz < 0) { + fclose(f); + return NULL; + } + rewind(f); + + buf = malloc(sz + 1); + if (!buf) { + fclose(f); + return NULL; + } + n = fread(buf, 1, sz, f); + if (ferror(f)) { + free(buf); + fclose(f); + return NULL; + } + buf[n] = '\0'; + fclose(f); + return buf; +} + +/* + * excl_edit() - the "nvme exclusion edit" command. + * + * Opens an exclusion list in the user's $EDITOR for hand-editing, in the same + * spirit as "visudo" or "crontab -e". It is a read-modify-write cycle with + * optimistic concurrency: + * + * 1. Read the current list through libnvme (libnvmf_exclusion_read), which + * also returns an opaque @version token snapshotting the on-disk content. + * 2. Copy the text to a private scratch file under $TMPDIR (0600). The + * editor needs a real path, but we never let it touch the live file in + * the exclusion directory. + * 3. run_editor() forks $EDITOR on the scratch file and waits for it to exit. + * 4. Validate the result; if it has bad entries, offer to re-open the editor + * (the re_edit loop) so the user can fix them without losing work. + * 5. Write the edited text back by list name (libnvmf_exclusion_write), + * passing @version. If the list changed on disk in the meantime the + * write fails with -ESTALE and nothing is clobbered. + * + * Whenever the changes are not saved (validation given up on, or -ESTALE), the + * scratch file is deliberately left in place and its path printed, so the + * user's edits are recoverable. Requires root. + */ +static int excl_edit(int argc, char **argv, struct command *acmd, + struct plugin *plugin) +{ + const char *desc = "Interactively edit an NVMeoF exclusion list."; + const char *name_help = "exclusion list name"; + __cleanup_nvme_global_ctx struct libnvme_global_ctx *ctx = NULL; + __cleanup_free char *text = NULL; + const char *tmpdir; + char tmp_path[PATH_MAX]; + uint64_t version; + int fd, ret; + + struct config { + char *name; + } cfg = { 0 }; + + NVME_ARGS(opts, + OPT_STRING("name", 'N', "NAME", &cfg.name, name_help)); + + ret = argconfig_parse(argc, argv, desc, opts); + if (ret) + return ret; + + if (!cfg.name) { + fprintf(stderr, "--name required\n"); + return -EINVAL; + } + + ret = require_root(); + if (ret) + return ret; + + ctx = libnvme_create_global_ctx(); + + /* + * Read the current list through libnvme: the plugin never needs to know + * where exclusion lists live. A missing list reads as empty so it can + * be created here. @version is an opaque token used at save time to + * detect a concurrent editor. + */ + ret = libnvmf_exclusion_read(ctx, cfg.name, &text, &version); + if (ret) { + fprintf(stderr, "cannot read '%s': %s\n", + cfg.name, libnvme_strerror(-ret)); + return ret; + } + + /* + * Edit a private scratch copy under $TMPDIR -- NOT in the exclusion dir. + * The editor needs a real path, but the result is installed by name via + * libnvmf_exclusion_write(). mkstemp() creates the copy 0600. + */ + tmpdir = getenv("TMPDIR"); + if (!tmpdir || !*tmpdir) + tmpdir = "/tmp"; + snprintf(tmp_path, sizeof(tmp_path), "%s/nvme-excl-%s.XXXXXX", + tmpdir, cfg.name); + + fd = mkstemp(tmp_path); + if (fd < 0) { + ret = -errno; + fprintf(stderr, "cannot create temp file: %s\n", + libnvme_strerror(-ret)); + return ret; + } + { + FILE *dst = fdopen(fd, "w"); + + if (!dst) { + ret = -errno; + close(fd); + unlink(tmp_path); + return ret; + } + fputs(text, dst); + fclose(dst); + } + +re_edit: + ret = run_editor(tmp_path); + if (ret) { + fprintf(stderr, "editor failed\n"); + unlink(tmp_path); + return ret; + } + + ret = validate_conf_file(ctx, tmp_path); + if (ret) { + char ans[8]; + + fprintf(stderr, "File has errors. Re-edit? [y/N] "); + fflush(stderr); + if (fgets(ans, sizeof(ans), stdin) && + (ans[0] == 'y' || ans[0] == 'Y')) + goto re_edit; + + fprintf(stderr, + "Discarding changes -- your edits are kept at %s\n", + tmp_path); + return -EINVAL; + } + + { + __cleanup_free char *edited = read_file(tmp_path); + + if (!edited) { + ret = errno ? -errno : -EIO; + fprintf(stderr, "cannot read back temp file: %s\n", + libnvme_strerror(-ret)); + unlink(tmp_path); + return ret; + } + ret = libnvmf_exclusion_write(ctx, cfg.name, edited, version); + } + + if (ret == -ESTALE) { + fprintf(stderr, + "the list changed on disk since you opened it; your " + "edits were NOT saved -- kept at %s\n", tmp_path); + return ret; + } + if (ret) { + fprintf(stderr, "cannot save: %s -- your edits are kept at %s\n", + libnvme_strerror(-ret), tmp_path); + return ret; + } + + unlink(tmp_path); + return 0; +} diff --git a/plugins/exclusion/exclusion-nvme.h b/plugins/exclusion/exclusion-nvme.h new file mode 100644 index 0000000000..499fd68325 --- /dev/null +++ b/plugins/exclusion/exclusion-nvme.h @@ -0,0 +1,29 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +/* + * This file is part of nvme-cli. + * Copyright (c) 2026 Dell Technologies Inc. or its subsidiaries. + * + * Authors: Martin Belanger + */ +#undef CMD_INC_FILE +#define CMD_INC_FILE plugins/exclusion/exclusion-nvme + +#if !defined(EXCLUSION_NVME) || defined(CMD_HEADER_MULTI_READ) +#define EXCLUSION_NVME + +#include "cmd.h" + +PLUGIN(NAME("exclusion", "NVMeoF system-wide exclusion list", NVME_VERSION), + COMMAND_LIST( + ENTRY("create", "Create an exclusion list", excl_create) + ENTRY("delete", "Delete an exclusion list", excl_delete) + ENTRY("edit", "Edit an exclusion list interactively", excl_edit) + ENTRY("list", "List exclusion lists or entries", excl_list) + ENTRY("add", "Add an entry to an exclusion list", excl_add) + ENTRY("remove", "Remove an entry from an exclusion list", excl_remove) + ) +); + +#endif + +#include "define_cmd.h" diff --git a/plugins/meson.build b/plugins/meson.build index 4590678b82..ab0e5c0eb5 100644 --- a/plugins/meson.build +++ b/plugins/meson.build @@ -54,6 +54,10 @@ if want_fabrics and 'nbft' in selected_plugins plugin_sources += ['plugins/nbft/nbft-plugin.c'] endif +if want_fabrics and 'exclusion' in selected_plugins + plugin_sources += ['plugins/exclusion/exclusion-nvme.c'] +endif + if want_fabrics and 'registry' in selected_plugins plugin_sources += ['plugins/registry/registry-nvme.c'] endif From 4add78a027d542580bd5abd461a49f0dd427328b Mon Sep 17 00:00:00 2001 From: Martin Belanger Date: Mon, 29 Jun 2026 09:38:26 -0400 Subject: [PATCH 6/9] nvme-cli/fabrics: add --exclude to nvme disconnect Add a -x/--exclude flag to "nvme disconnect" that records a matching exclusion entry before tearing the controller (or subsystem) down, so an orchestrator sees the exclusion before the removal event fires and does not immediately reconnect. The entry is built inside libnvme (libnvmf_exclusion_add_ctrl for a device, libnvmf_exclusion_add_nqn for an NQN); the CLI keeps no knowledge of the on-disk entry format. Signed-off-by: Martin Belanger --- fabrics.c | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/fabrics.c b/fabrics.c index af27b726e6..a134e5512d 100644 --- a/fabrics.c +++ b/fabrics.c @@ -830,6 +830,7 @@ static void nvmf_disconnect_nqn(struct libnvme_global_ctx *ctx, char *nqn) int fabrics_disconnect(const char *desc, int argc, char **argv) { const char *device = "nvme device handle"; + const char *exclude_help = "write exclusion entry before disconnecting"; __cleanup_nvme_global_ctx struct libnvme_global_ctx *ctx = NULL; libnvme_ctrl_t c; char *p; @@ -838,13 +839,15 @@ int fabrics_disconnect(const char *desc, int argc, char **argv) struct config { char *nqn; char *device; + bool exclude; }; struct config cfg = { 0 }; NVME_ARGS(opts, - OPT_STRING("nqn", 'n', "NAME", &cfg.nqn, nvmf_nqn), - OPT_STRING("device", 'd', "DEV", &cfg.device, device)); + OPT_STRING("nqn", 'n', "NAME", &cfg.nqn, nvmf_nqn), + OPT_STRING("device", 'd', "DEV", &cfg.device, device), + OPT_FLAG("exclude", 'x', &cfg.exclude, exclude_help)); ret = argconfig_parse(argc, argv, desc, opts); if (ret) @@ -887,8 +890,23 @@ int fabrics_disconnect(const char *desc, int argc, char **argv) return ret; } - if (cfg.nqn) + if (cfg.nqn) { + /* + * Disconnecting by NQN affects every controller of that + * subsystem; with --exclude, write a matching subsysnqn= + * exclusion to the main list first so orchestrators see it + * before the removal events fire. + */ + if (cfg.exclude) { + ret = libnvmf_exclusion_add_subsysnqn(ctx, NULL, + cfg.nqn); + if (ret) + fprintf(stderr, + "Warning: failed to write exclusion entry: %s\n", + libnvme_strerror(-ret)); + } nvmf_disconnect_nqn(ctx, cfg.nqn); + } if (cfg.device) { char *d; @@ -903,6 +921,19 @@ int fabrics_disconnect(const char *desc, int argc, char **argv) "Did not find device %s\n", p); return -ENODEV; } + /* + * Write exclusion entry before disconnecting so that + * orchestrators see the exclusion in place before the + * device removal event fires. + */ + if (cfg.exclude) { + ret = libnvmf_exclusion_add_ctrl(ctx, NULL, + c); + if (ret) + fprintf(stderr, + "Warning: failed to write exclusion entry: %s\n", + libnvme_strerror(-ret)); + } ret = libnvmf_disconnect_ctrl(c); if (ret) fprintf(stderr, From 003a4d1b06d0899019a9b0dd8392eae30ee37e18 Mon Sep 17 00:00:00 2001 From: Martin Belanger Date: Mon, 29 Jun 2026 12:53:33 -0400 Subject: [PATCH 7/9] doc: add NVMe-oF exclusion list documentation Add an overview of the NVMe-oF exclusion list in libnvme (EXCLUSIONS.md) -- the on-disk format, the field keys, the minimal-match semantics, and how it complements the ownership registry as a cooperative layer for orchestrator coexistence -- together with man pages for the six "nvme exclusion" subcommands and the new "nvme disconnect --exclude" flag. Signed-off-by: Martin Belanger --- Documentation/meson.build | 6 + Documentation/nvme-disconnect.txt | 21 ++++ Documentation/nvme-exclusion-add.txt | 67 ++++++++++++ Documentation/nvme-exclusion-create.txt | 48 ++++++++ Documentation/nvme-exclusion-delete.txt | 46 ++++++++ Documentation/nvme-exclusion-edit.txt | 60 ++++++++++ Documentation/nvme-exclusion-list.txt | 53 +++++++++ Documentation/nvme-exclusion-remove.txt | 47 ++++++++ libnvme/design/EXCLUSIONS.md | 140 ++++++++++++++++++++++++ 9 files changed, 488 insertions(+) create mode 100644 Documentation/nvme-exclusion-add.txt create mode 100644 Documentation/nvme-exclusion-create.txt create mode 100644 Documentation/nvme-exclusion-delete.txt create mode 100644 Documentation/nvme-exclusion-edit.txt create mode 100644 Documentation/nvme-exclusion-list.txt create mode 100644 Documentation/nvme-exclusion-remove.txt create mode 100644 libnvme/design/EXCLUSIONS.md diff --git a/Documentation/meson.build b/Documentation/meson.build index 07833588b6..a2e9a240af 100644 --- a/Documentation/meson.build +++ b/Documentation/meson.build @@ -30,6 +30,12 @@ adoc_sources = [ 'nvme-endurance-event-agg-log', 'nvme-endurance-log', 'nvme-error-log', + 'nvme-exclusion-add', + 'nvme-exclusion-create', + 'nvme-exclusion-delete', + 'nvme-exclusion-edit', + 'nvme-exclusion-list', + 'nvme-exclusion-remove', 'nvme-fdp-configs', 'nvme-fdp-events', 'nvme-fdp-feature', diff --git a/Documentation/nvme-disconnect.txt b/Documentation/nvme-disconnect.txt index 534f64b9af..dbd17fe1c8 100644 --- a/Documentation/nvme-disconnect.txt +++ b/Documentation/nvme-disconnect.txt @@ -10,6 +10,7 @@ SYNOPSIS [verse] 'nvme' [] 'disconnect' [--nqn= | -n ] [--device= | -d ] + [--exclude | -x] DESCRIPTION ----------- @@ -18,6 +19,12 @@ If the --nqn option is specified all controllers connecting to the Subsystem identified by subnqn will be removed. If the --device option is specified the controller specified by the --device option will be removed. +With --exclude, a matching entry is written to the system-wide NVMe-oF +exclusion list before the controller (or subsystem) is torn down, so that a +cooperating orchestrator sees the exclusion in place before the removal event +fires and does not immediately reconnect. See nvme-exclusion-add(1) and the +exclusion list overview for details. + OPTIONS ------- -n :: @@ -30,6 +37,13 @@ OPTIONS Indicates that the controller with the specified name should be removed. +-x:: +--exclude:: + Write a matching entry to the 'user' exclusion list before + disconnecting, so orchestrators do not auto-reconnect the + controller. The entry is built from the controller's transport + parameters (or, with --nqn, from the subsystem NQN). + include::global-options.txt[] EXAMPLES @@ -47,9 +61,16 @@ nqn.2014-08.com.example:nvme:nvm-subsystem-sn-d78432: # nvme disconnect --device=nvme4 ------------ +* Disconnect nvme4 and exclude it so orchestrators do not reconnect it: ++ +------------ +# nvme disconnect --device=nvme4 --exclude +------------ + SEE ALSO -------- nvme-connect(1) +nvme-exclusion-add(1) NVME ---- diff --git a/Documentation/nvme-exclusion-add.txt b/Documentation/nvme-exclusion-add.txt new file mode 100644 index 0000000000..16da871960 --- /dev/null +++ b/Documentation/nvme-exclusion-add.txt @@ -0,0 +1,67 @@ +nvme-exclusion-add(1) +===================== + +NAME +---- +nvme-exclusion-add - Add an entry to an NVMe-oF exclusion list + +SYNOPSIS +-------- +[verse] +'nvme' [] 'exclusion add' --name= | -N + --entry= | -e + +DESCRIPTION +----------- +Append an entry to the NVMe-oF exclusion list named , creating the +list if it does not exist. The entry is a semicolon-separated list of +key=value fields; a controller is excluded when it matches every field +present in the entry. + +The recognized field keys are the 'nvme connect' option names: transport, +traddr, trsvcid, nqn, host-traddr, host-iface, hostnqn and hostid. Only +the fields given are matched, so a single key excludes broadly and more +keys narrow the match. See the exclusion list overview (EXCLUSIONS.md) +for the full match semantics. + +The entry is validated before it is written; an entry with an unknown +key, or with no recognized field, is rejected. This command requires +root. + +OPTIONS +------- +-N :: +--name=:: + Name of the exclusion list to add to. + +-e :: +--entry=:: + The entry to add, e.g. "transport=tcp;traddr=192.0.2.10;nqn=...". + +include::global-options.txt[] + +EXAMPLES +-------- +* Exclude a single path to a subsystem: ++ +------------ +# nvme exclusion add --name user \ + --entry "transport=tcp;traddr=192.0.2.10;trsvcid=4420;nqn=nqn.2024-01.com.example:vol1" +------------ + +* Exclude a whole subsystem on every transport and address: ++ +------------ +# nvme exclusion add --name user --entry "nqn=nqn.2024-01.com.example:retired" +------------ + +SEE ALSO +-------- +nvme-exclusion-remove(1) +nvme-exclusion-list(1) +nvme-exclusion-edit(1) +nvme-disconnect(1) + +NVME +---- +Part of the nvme-user suite diff --git a/Documentation/nvme-exclusion-create.txt b/Documentation/nvme-exclusion-create.txt new file mode 100644 index 0000000000..3ee69e7778 --- /dev/null +++ b/Documentation/nvme-exclusion-create.txt @@ -0,0 +1,48 @@ +nvme-exclusion-create(1) +======================== + +NAME +---- +nvme-exclusion-create - Create a new NVMe-oF exclusion list + +SYNOPSIS +-------- +[verse] +'nvme' [] 'exclusion create' --name= | -N + +DESCRIPTION +----------- +Create a new, empty NVMe-oF exclusion list named , stored as +/etc/nvme/exclusions.conf.d/.conf. The exclusion list names controllers +that no orchestrator may auto-connect; see the exclusion list overview +in the libnvme documentation (EXCLUSIONS.md) for the format, the field +keys, and the match semantics. + +This command requires root. + +OPTIONS +------- +-N :: +--name=:: + Name of the exclusion list to create. + +include::global-options.txt[] + +EXAMPLES +-------- +* Create a list for controllers under maintenance: ++ +------------ +# nvme exclusion create --name maintenance +------------ + +SEE ALSO +-------- +nvme-exclusion-add(1) +nvme-exclusion-list(1) +nvme-exclusion-delete(1) +nvme-disconnect(1) + +NVME +---- +Part of the nvme-user suite diff --git a/Documentation/nvme-exclusion-delete.txt b/Documentation/nvme-exclusion-delete.txt new file mode 100644 index 0000000000..8e2c65fca0 --- /dev/null +++ b/Documentation/nvme-exclusion-delete.txt @@ -0,0 +1,46 @@ +nvme-exclusion-delete(1) +======================== + +NAME +---- +nvme-exclusion-delete - Delete an NVMe-oF exclusion list + +SYNOPSIS +-------- +[verse] +'nvme' [] 'exclusion delete' --name= | -N + +DESCRIPTION +----------- +Delete the NVMe-oF exclusion list named in its entirety, removing +/etc/nvme/exclusions.conf.d/.conf and every entry it contains. To +drop a single entry instead, use 'nvme exclusion remove'. + +This command requires root. + +OPTIONS +------- +-N :: +--name=:: + Name of the exclusion list to delete. + +include::global-options.txt[] + +EXAMPLES +-------- +* Remove the maintenance list once the work is done: ++ +------------ +# nvme exclusion delete --name maintenance +------------ + +SEE ALSO +-------- +nvme-exclusion-remove(1) +nvme-exclusion-list(1) +nvme-exclusion-create(1) +nvme-disconnect(1) + +NVME +---- +Part of the nvme-user suite diff --git a/Documentation/nvme-exclusion-edit.txt b/Documentation/nvme-exclusion-edit.txt new file mode 100644 index 0000000000..5c5cc84b00 --- /dev/null +++ b/Documentation/nvme-exclusion-edit.txt @@ -0,0 +1,60 @@ +nvme-exclusion-edit(1) +====================== + +NAME +---- +nvme-exclusion-edit - Edit an NVMe-oF exclusion list in an editor + +SYNOPSIS +-------- +[verse] +'nvme' [] 'exclusion edit' --name= | -N + +DESCRIPTION +----------- +Open the NVMe-oF exclusion list named in the editor named by the +$EDITOR environment variable, in the same spirit as 'visudo' or +'crontab -e'. A missing list is created. + +The list is edited through a private scratch copy and installed by name +only when the editor exits. On save the result is validated; if it +contains a bad entry you are offered the chance to re-open the editor and +fix it. If the list changed on disk while you were editing, the save is +refused so a concurrent editor's changes are never silently overwritten. +Whenever the changes are not saved, the scratch copy is kept and its path +printed so your edits are recoverable. + +This command requires root. + +OPTIONS +------- +-N :: +--name=:: + Name of the exclusion list to edit. + +include::global-options.txt[] + +EXAMPLES +-------- +* Edit the 'user' list: ++ +------------ +# nvme exclusion edit --name user +------------ + +* Use a specific editor: ++ +------------ +# EDITOR=vi nvme exclusion edit --name user +------------ + +SEE ALSO +-------- +nvme-exclusion-add(1) +nvme-exclusion-remove(1) +nvme-exclusion-list(1) +nvme-disconnect(1) + +NVME +---- +Part of the nvme-user suite diff --git a/Documentation/nvme-exclusion-list.txt b/Documentation/nvme-exclusion-list.txt new file mode 100644 index 0000000000..52a109fb22 --- /dev/null +++ b/Documentation/nvme-exclusion-list.txt @@ -0,0 +1,53 @@ +nvme-exclusion-list(1) +====================== + +NAME +---- +nvme-exclusion-list - List NVMe-oF exclusion lists or their entries + +SYNOPSIS +-------- +[verse] +'nvme' [] 'exclusion list' [--name= | -N ] + +DESCRIPTION +----------- +Without --name, print the name of every named exclusion list under +/etc/nvme/exclusions.conf.d/. With --name, print the entries contained +in that one list. + +This command is read-only and does not require root. + +OPTIONS +------- +-N :: +--name=:: + List the entries in the named exclusion list. Omit to list all + exclusion lists instead. + +include::global-options.txt[] + +EXAMPLES +-------- +* Show every exclusion list: ++ +------------ +# nvme exclusion list +------------ + +* Show the entries in the 'user' list: ++ +------------ +# nvme exclusion list --name user +------------ + +SEE ALSO +-------- +nvme-exclusion-add(1) +nvme-exclusion-remove(1) +nvme-exclusion-create(1) +nvme-disconnect(1) + +NVME +---- +Part of the nvme-user suite diff --git a/Documentation/nvme-exclusion-remove.txt b/Documentation/nvme-exclusion-remove.txt new file mode 100644 index 0000000000..6831990f85 --- /dev/null +++ b/Documentation/nvme-exclusion-remove.txt @@ -0,0 +1,47 @@ +nvme-exclusion-remove(1) +======================== + +NAME +---- +nvme-exclusion-remove - Remove an entry from an NVMe-oF exclusion list + +SYNOPSIS +-------- +[verse] +'nvme' [] 'exclusion remove' --name= | -N + +DESCRIPTION +----------- +Interactively remove a single entry from the NVMe-oF exclusion list named +. The current entries are shown and one is selected for removal; +the rest of the list is left untouched. To delete a whole list at once, +use 'nvme exclusion delete'. + +This command requires root. + +OPTIONS +------- +-N :: +--name=:: + Name of the exclusion list to remove an entry from. + +include::global-options.txt[] + +EXAMPLES +-------- +* Remove an entry from the 'user' list: ++ +------------ +# nvme exclusion remove --name user +------------ + +SEE ALSO +-------- +nvme-exclusion-add(1) +nvme-exclusion-edit(1) +nvme-exclusion-delete(1) +nvme-disconnect(1) + +NVME +---- +Part of the nvme-user suite diff --git a/libnvme/design/EXCLUSIONS.md b/libnvme/design/EXCLUSIONS.md new file mode 100644 index 0000000000..fcfa177354 --- /dev/null +++ b/libnvme/design/EXCLUSIONS.md @@ -0,0 +1,140 @@ +# NVMe-oF Exclusion List + +The exclusion list lets a local administrator name NVMe-oF controllers that **no orchestrator may auto-connect**. It is a small, cooperative coordination layer — the administrator's opt-out — that sits alongside the [ownership registry](REGISTRY.md): the registry records *who owns* a controller, while the exclusion list keeps chosen controllers *out of every orchestrator's reach entirely*. + +> The code is the source of truth. This document summarizes behavior and intent; for exact signatures see the header kdoc in `src/nvme/exclusion.h` and the `nvme-exclusion-*` man pages. + +## Why it exists + +NVMe-oF connections on a host are rarely managed by a single actor. Independent **orchestrators** — agents that decide on their own which controllers to connect — coexist on the same machine: the **initramfs** (NBFT / FC-kickstart boot connections), a **human** running `nvme connect-all`, and daemons such as **nvme-discoverd** or **nvme-stas**. An orchestrator that browses mDNS or walks a discovery log page will, left to itself, connect *everything* it finds. + +Sometimes that is exactly wrong. An administrator may need to keep a particular subsystem, target, or whole transport out of service — a controller under maintenance, a misbehaving target, a path reserved for another host — without having every orchestrator on the box reconnect it the moment it is torn down. There is no per-controller "do not connect" switch in the kernel, and telling each orchestrator separately does not scale and does not cover the ones not yet installed. + +Crucially, the connection intent often comes from a source the administrator **cannot edit**, which is what makes a host-wide opt-out necessary rather than merely convenient: + +- **NBFT controllers** are described by a firmware boot table configured in UEFI — there is no host-side text entry to comment out. Excluding the controller is the only way to stop an orchestrator from (re)connecting an NBFT path, for example to take it out of service during testing. +- **Discovered controllers** — a target returned as a DC's discovery log page entry (DLPE), or a controller announced over mDNS — are advertised by the fabric at runtime, not listed in any file on the host. There is nothing local to remove. + +For all of these the exclusion list is the only local lever: the one place an administrator can say "not this one", regardless of where the connection request originated. + +The exclusion list is that switch: a single, host-wide, admin-authored statement of intent that every cooperating orchestrator consults before connecting. Like the registry, it is a **cooperative tool, not an enforcement mechanism** — every orchestrator runs as root and *could* connect anything. The list simply lets well-behaved tools refrain by agreement. + +## What it is + +The exclusion list lives in `/etc/nvme/exclusions.conf`, with optional named drop-in lists under `/etc/nvme/exclusions.conf.d/` — **persistent administrative configuration** (in `/etc`, hand-authored, surviving reboot). This is the opposite of the registry, which is *runtime* state under `/run` written by the library itself. The exclusion list is written by a human (or by `nvme disconnect --exclude` on their behalf) and read by orchestrators. + +``` +/etc/nvme/ + exclusions.conf # the default list; disconnect --exclude appends here + exclusions.conf.d/ # optional named drop-in lists + maintenance.conf + lab.conf +``` + +The main `exclusions.conf` is the **default list**, and `nvme disconnect --exclude` appends to it. Additional **named lists** are kept as drop-in files under `exclusions.conf.d/` — the name is just an organizing label an administrator can create for a purpose (e.g. `maintenance`) and remove wholesale when done. This mirrors the systemd `foo.conf` + `foo.conf.d/` convention. Matching considers the main file **and** every drop-in. + +Each file holds an `[exclusions]` INI section with any number of **entries**, one per line: + +```ini +# NVMe-oF exclusion list + +[exclusions] +exclusion = transport=tcp;traddr=192.0.2.10;trsvcid=4420;nqn=nqn.2024-01.com.example:vol1 +exclusion = nqn=nqn.2024-01.com.example:retired + +# Exclude the eth3 interface (TCP-only) +exclusion = host-iface=eth3 +``` + +- Lines beginning with `#` are comments; blank lines are ignored. +- Each entry is the literal key `exclusion`, an `=`, then a semicolon-separated list of `key=value` fields. An entry's value is **pure transport identity (TID)** — unlike the connection config's `controller =` lines, it never carries connect tunables, because it *identifies* connections rather than establishing them. +- Entries count only inside the `[exclusions]` section. Other sections are reserved for future use: readers ignore their content, so a file from a newer version stays readable. Reading is fail-safe (a stray or malformed line is skipped); writing through the library is strict (a malformed section header or an entry outside `[exclusions]` is rejected with `-EINVAL`, so an editor cannot persist a silently-disarmed entry). +- Files are installed world-readable, root-writable (`0644`); the drop-in directory is created on demand. +- Writes are atomic (`mkstemp` → `fsync` → `rename`), so concurrent writers never corrupt a list and readers never see a half-written file. + +### Entry fields + +An entry constrains a connection on one or more of these fields. **The keys are named to match the corresponding `nvme connect` options** — what you would type on the command line is what you put in the file: + +| Field (`nvme connect` option) | Meaning | +|---|---| +| `transport` | Transport type: `tcp`, `rdma`, `fc`, `loop` | +| `traddr` | Transport (target) address | +| `trsvcid` | Transport service id (e.g. `4420`) | +| `nqn` | Subsystem NQN | +| `host-traddr` | Host (source) address | +| `host-iface` | Host interface (TCP only) | +| `hostnqn` | Host NQN | +| `hostid` | Host Identifier | + +Each key has exactly one spelling — the option name, hyphenated where `nvme connect` hyphenates it (`host-traddr`, `host-iface`). The library's C struct spells the same fields with underscores (`subsysnqn`, `host_traddr`, `host_iface`) because C identifiers cannot contain hyphens, but those code-side names are never valid in a file: an unrecognized key (a typo, or a code-style spelling like `subsysnqn`) makes the whole entry invalid, and an invalid entry matches nothing (see [Match semantics](#match-semantics)). + +## Match semantics + +A controller is **excluded** if it matches **any** entry in **any** list. Matching is intentionally **minimal — only the fields present in an entry are compared**: + +- **Field present in the entry, value present on the connection** → compare the two. +- **Field present in the entry, but the connection has no value for it** → the entry does **not** match. A NULL connection field means "this connection has no value here", never "match anything". +- **Field absent from the entry** → not checked; the entry can still match on its other fields. +- **Unknown key in an entry** → that entry **never matches** (fail-safe): a typo weakens nothing. +- **Empty or field-less entry** → never matches; it cannot be used to exclude everything by accident. + +This makes an entry as broad or as narrow as the administrator writes it. `nqn=nqn.…:retired` excludes that subsystem on every transport and address; adding `traddr=` and `trsvcid=` narrows it to one path. + +Address fields are compared **address-aware, not byte-for-byte**: equivalent IP spellings match (e.g. a compressed vs. expanded IPv6 address), while Fibre Channel addresses are compared case-insensitively. (The connection's identity is captured in a transport-ID value — see `src/nvme/tid.h` — the same building block the registry and discoverd use for a stable per-connection identity.) + +If the exclusion files cannot be read at all, matching returns "not excluded" (**fail-open**): a missing or unreadable list never blocks connectivity. + +## How orchestrators use it + +Before auto-connecting a discovered controller, a cooperating orchestrator asks libnvme whether it is excluded and, if so, skips it: + +```c +if (libnvmf_exclusion_match(ctx, tid)) + continue; /* administrator excluded this controller */ +``` + +`libnvmf_exclusion_match()` re-reads the directory on every call (no caching), so an edit takes effect on the next connection attempt without restarting anything. This is the only API an orchestrator needs; the create/add/remove calls are for the management tooling. + +The match is by design *not* applied to `nvme connect ` or `nvme disconnect `: those are single, targeted human actions where the operator's intent is explicit. The list governs the *orchestrating* paths that decide on their own. + +## Managing the list + +From the CLI (all mutating commands require root): + +```sh +nvme exclusion list # all lists +nvme exclusion list -N user # entries in one list +nvme exclusion add -N user -e "transport=tcp;traddr=192.0.2.10;nqn=nqn.…:vol1" +nvme exclusion remove -N user # interactively pick an entry to remove +nvme exclusion edit -N user # open the list in $EDITOR (visudo-style) +nvme exclusion create -N maintenance # start a new, empty named list +nvme exclusion delete -N maintenance # remove a whole list +``` + +`nvme disconnect --exclude` (`-x`) is the common producer: it records a matching entry in the default list (`exclusions.conf`) *before* tearing a controller down, so an orchestrator sees the exclusion in place before the removal event fires and does not immediately reconnect. + +The C API mirrors the commands (`libnvmf_exclusion_create` / `_delete` / `_add` / `_add_ctrl` / `_add_subsysnqn` / `_remove` / `_list_for_each` / `_entry_for_each` / `_match`, plus `_read` / `_write` for the read-modify-write editor), and SWIG exposes `exclusion_lists()`, `exclusion_entries()`, and `exclusion_match()` to Python. `libnvmf_exclusion_add_ctrl()` and `libnvmf_exclusion_add_subsysnqn()` build the entry inside libnvme so callers never encode the on-disk format themselves. + +For testing, `libnvme_set_test_base_dir()` reroots the exclusion files (and the registry) under a throwaway `/tmp` sandbox; the shell-invoked `nvme` binary uses the `LIBNVME_TEST_BASE_DIR` environment variable for the same purpose. Both are confined to `/tmp` so a test can never redirect writes onto a production path. + +## Relationship to the registry + +The exclusion list and the ownership registry are complementary cooperative layers for the same goal — letting independent orchestrators share a host: + +| | Registry | Exclusion list | +|---|---|---| +| Question | *Who owns this controller?* | *May anyone connect this controller?* | +| Guards against | accidental **disconnect** | accidental **connect** | +| Location | `/run/nvme/registry/` (runtime, per-boot) | `/etc/nvme/exclusions.conf` + `.conf.d/` (persistent config) | +| Author | libnvme, automatically on connect | the administrator (or `nvme disconnect --exclude`) | +| Consulted by | `disconnect-all` (protect owned controllers) | every orchestrator before auto-connecting | + +The two are mirror images: the registry stops a sweeping command like `disconnect-all` from **accidentally disconnecting** a controller some component still depends on, while the exclusion list stops an orchestrator from **accidentally (re)connecting** a controller the administrator wants left alone. Both rest on the same transport-ID identity and the same principle: cooperation by convention, not enforcement. + +## Further reading + +- `src/nvme/exclusion.h` — full API kdoc +- `src/nvme/tid.h` — the transport-ID identity used for matching +- `Documentation/nvme-exclusion-*.txt` — man pages for the CLI commands +- [REGISTRY.md](REGISTRY.md) — the companion ownership registry From e90344e25ce48287c19c73a47475312277c8aa39 Mon Sep 17 00:00:00 2001 From: Martin Belanger Date: Tue, 30 Jun 2026 16:52:20 -0400 Subject: [PATCH 8/9] doc: move REGISTRY.md under libnvme/design/ The hand-written markdown design docs were cluttering the top-level libnvme/ listing. Move REGISTRY.md into a libnvme/design/ subdirectory, kept distinct from libnvme/doc/ (the sphinx / read-the-docs source), to be read directly on GitHub. EXCLUSIONS.md lands there in the same series. Signed-off-by: Martin Belanger --- libnvme/{ => design}/REGISTRY.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename libnvme/{ => design}/REGISTRY.md (100%) diff --git a/libnvme/REGISTRY.md b/libnvme/design/REGISTRY.md similarity index 100% rename from libnvme/REGISTRY.md rename to libnvme/design/REGISTRY.md From 2a1616e072f1a8207d1b26e639d57a86c51116c3 Mon Sep 17 00:00:00 2001 From: Martin Belanger Date: Tue, 30 Jun 2026 16:59:19 -0400 Subject: [PATCH 9/9] nvme-cli/fabrics: note an excluded target on connect under --verbose The exclusion list governs auto-connecting orchestrators, not an explicit "nvme connect", so connect must never be blocked by it. But an operator who hand-connects a controller they previously excluded is overriding their own opt-out, often unknowingly. Under --verbose, consult the list and print a note when the target matches, making the override visible without changing behaviour. Document the courtesy in EXCLUSIONS.md. Signed-off-by: Martin Belanger --- fabrics.c | 20 ++++++++++++++++++++ libnvme/design/EXCLUSIONS.md | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/fabrics.c b/fabrics.c index a134e5512d..bcb818957b 100644 --- a/fabrics.c +++ b/fabrics.c @@ -770,6 +770,26 @@ int fabrics_connect(const char *desc, int argc, char **argv) if (config_file) return libnvmf_connect_config_json(ctx, fctx); + /* + * The exclusion list governs auto-connecting orchestrators, not an + * explicit "nvme connect", so we never block it here. But under + * --verbose, note when the target matches an exclusion entry so the + * operator knows they are overriding their own opt-out. + */ + if (nvme_args.verbose) { + struct libnvmf_tid *tid; + + tid = libnvmf_tid_from_fields(fa.transport, fa.traddr, + fa.trsvcid, fa.subsysnqn, + fa.host_traddr, fa.host_iface, + fa.hostnqn, fa.hostid); + if (tid && libnvmf_exclusion_match(ctx, tid)) + fprintf(stderr, + "Note: %s is on the exclusion list; connecting anyway\n", + fa.subsysnqn ? fa.subsysnqn : "this controller"); + libnvmf_tid_free(tid); + } + ret = libnvmf_connect(ctx, fctx); if (ret) { fprintf(stderr, "failed to connect: %s\n", diff --git a/libnvme/design/EXCLUSIONS.md b/libnvme/design/EXCLUSIONS.md index fcfa177354..c1e10e9cde 100644 --- a/libnvme/design/EXCLUSIONS.md +++ b/libnvme/design/EXCLUSIONS.md @@ -94,9 +94,9 @@ if (libnvmf_exclusion_match(ctx, tid)) continue; /* administrator excluded this controller */ ``` -`libnvmf_exclusion_match()` re-reads the directory on every call (no caching), so an edit takes effect on the next connection attempt without restarting anything. This is the only API an orchestrator needs; the create/add/remove calls are for the management tooling. +`libnvmf_exclusion_match()` re-reads the files on every call (no caching), so an edit takes effect on the next connection attempt without restarting anything. This is the only API an orchestrator needs; the create/add/remove calls are for the management tooling. -The match is by design *not* applied to `nvme connect ` or `nvme disconnect `: those are single, targeted human actions where the operator's intent is explicit. The list governs the *orchestrating* paths that decide on their own. +The match is by design *not* allowed to **block** `nvme connect ` or `nvme disconnect `: those are single, targeted human actions where the operator's intent is explicit. The list governs the *orchestrating* paths that decide on their own. As a courtesy, `nvme connect --verbose` does *consult* the list and prints a note when the target matches — a heads-up that you are overriding your own opt-out — but it still connects. ## Managing the list