git: 48efb6c4eb9f - main - gzoned: Introduce Zoned Storage emulator

From: Alan Somers <asomers_at_FreeBSD.org>
Date: Thu, 03 Sep 2026 18:27:15 UTC
The branch main has been updated by asomers:

URL: https://cgit.FreeBSD.org/src/commit/?id=48efb6c4eb9febc1c8c50d13a794e9165af71930

commit 48efb6c4eb9febc1c8c50d13a794e9165af71930
Author:     voidanix <voidanix@freebsd.org>
AuthorDate: 2026-06-13 15:57:21 +0000
Commit:     Alan Somers <asomers@FreeBSD.org>
CommitDate: 2026-09-03 18:27:00 +0000

    gzoned: Introduce Zoned Storage emulator
    
    gzoned(8) is a new GEOM class that exposes a host-managed zoned device
    (similar to ZAC/ZBC drives) on top of regular, non-zoned providers.
    
    The created medium is sliced into equally sized zones, by default
    sequential-write-required.  Such zones can be turned into conventional
    zones if desired.  The zoned drive's state and configuration is
    persistent through metadata at the tail of the backing provider, meaning
    the zoned device gets recreated at the provider retaste.  Zone state
    changes only mark the table dirty with BIO_FLUSH committing it,
    mirroring drives whose zone state is volatile until a cache flush.
    
    The new class tries to emulate real zoned drives by incorporating
    per-zone write pointers and support for BIO_ZONE management commands.
    Fault emulation through zone conditions (RWP recommended, offline, R/O),
    URSWRZ bit toggling and concurrent open zone limits are additional
    features useful for testing.
    
    While at it, fix zonectl's report of write pointer LBAs for zones that
    should have none, remove its forced debug flags and allow the geom(8)
    shared subroutines to detect host-managed drives.
    
    ATF-sh tests for gzoned(8) and zonectl(8) (dogfooded by gzoned(8)) are
    included, featuring helpers (see zoned_subr.sh) for testing zoned
    storage support in other components or GEOM classes.
    
    Reviewed by:    asomers, fuz, ken
    Sponsored by:   Google Summer of Code 2026
    Pull Request:   https://github.com/freebsd/freebsd-src/pull/2326
---
 etc/mtree/BSD.include.dist               |    2 +
 etc/mtree/BSD.tests.dist                 |    4 +
 include/Makefile                         |    1 +
 lib/geom/Makefile.classes                |    1 +
 lib/geom/zoned/Makefile                  |    5 +
 lib/geom/zoned/Makefile.depend           |   16 +
 lib/geom/zoned/geom_zoned.c              |  289 +++++
 lib/geom/zoned/gzoned.8                  |  260 +++++
 sbin/geom/core/geom.8                    |    7 +-
 sbin/geom/misc/subr.c                    |   20 +
 sbin/geom/misc/subr.h                    |    1 +
 sys/geom/zoned/g_zoned.c                 | 1819 ++++++++++++++++++++++++++++++
 sys/geom/zoned/g_zoned.h                 |  291 +++++
 sys/modules/geom/Makefile                |    3 +-
 sys/modules/geom/geom_zoned/Makefile     |    6 +
 sys/sys/disk_zone.h                      |   20 +-
 sys/sys/exterr_cat.h                     |    1 +
 tests/sys/geom/class/Makefile            |    2 +
 tests/sys/geom/class/zoned/Makefile      |    9 +
 tests/sys/geom/class/zoned/conf.sh       |   59 +
 tests/sys/geom/class/zoned/zoned_test.sh | 1056 +++++++++++++++++
 tests/sys/geom/class/zoned_subr.sh       |   43 +
 usr.sbin/zonectl/Makefile                |    6 +-
 usr.sbin/zonectl/tests/Makefile          |    5 +
 usr.sbin/zonectl/tests/zonectl_test.sh   |  221 ++++
 usr.sbin/zonectl/zonectl.c               |   10 +-
 26 files changed, 4142 insertions(+), 15 deletions(-)

diff --git a/etc/mtree/BSD.include.dist b/etc/mtree/BSD.include.dist
index 05e739bcf5dd..75c66beeefbe 100644
--- a/etc/mtree/BSD.include.dist
+++ b/etc/mtree/BSD.include.dist
@@ -333,6 +333,8 @@
         ..
         virstor
         ..
+        zoned
+        ..
     ..
     gssapi              tags=package=kerberos-dev
     ..
diff --git a/etc/mtree/BSD.tests.dist b/etc/mtree/BSD.tests.dist
index 3843ac9254b5..20f3b36ab40b 100644
--- a/etc/mtree/BSD.tests.dist
+++ b/etc/mtree/BSD.tests.dist
@@ -871,6 +871,8 @@
                 ..
                 virstor
                 ..
+                zoned
+                ..
             ..
         ..
         kern
@@ -1323,6 +1325,8 @@
         ..
         traceroute
         ..
+        zonectl
+        ..
     ..
 ..
 
diff --git a/include/Makefile b/include/Makefile
index bfa82a0bb706..0cf926a6e00f 100644
--- a/include/Makefile
+++ b/include/Makefile
@@ -60,6 +60,7 @@ LSUBDIRS=	dev/acpica dev/agp dev/ciss dev/filemon dev/firewire \
 	geom/cache geom/concat geom/eli geom/gate geom/journal geom/label \
 	geom/mirror geom/mountver geom/multipath geom/nop \
 	geom/raid geom/raid3 geom/shsec geom/stripe geom/union geom/virstor \
+	geom/zoned \
 	net/altq \
 	net/route \
 	netgraph/netflow \
diff --git a/lib/geom/Makefile.classes b/lib/geom/Makefile.classes
index 1852e35be15f..753495c9ff10 100644
--- a/lib/geom/Makefile.classes
+++ b/lib/geom/Makefile.classes
@@ -22,3 +22,4 @@ GEOM_CLASSES+=	shsec
 GEOM_CLASSES+=	stripe
 GEOM_CLASSES+=	union
 GEOM_CLASSES+=	virstor
+GEOM_CLASSES+=	zoned
diff --git a/lib/geom/zoned/Makefile b/lib/geom/zoned/Makefile
new file mode 100644
index 000000000000..d80b0081c477
--- /dev/null
+++ b/lib/geom/zoned/Makefile
@@ -0,0 +1,5 @@
+PACKAGE=geom
+
+GEOM_CLASS=	zoned
+
+.include <bsd.lib.mk>
diff --git a/lib/geom/zoned/Makefile.depend b/lib/geom/zoned/Makefile.depend
new file mode 100644
index 000000000000..0dd05cace3c0
--- /dev/null
+++ b/lib/geom/zoned/Makefile.depend
@@ -0,0 +1,16 @@
+# Autogenerated - do NOT edit!
+
+DIRDEPS = \
+	include \
+	include/xlocale \
+	lib/${CSU_DIR} \
+	lib/libc \
+	lib/libcompiler_rt \
+	lib/libgeom \
+
+
+.include <dirdeps.mk>
+
+.if ${DEP_RELDIR} == ${_DEP_RELDIR}
+# local dependencies - needed for -jN in clean tree
+.endif
diff --git a/lib/geom/zoned/geom_zoned.c b/lib/geom/zoned/geom_zoned.c
new file mode 100644
index 000000000000..2c112949fd7b
--- /dev/null
+++ b/lib/geom/zoned/geom_zoned.c
@@ -0,0 +1,289 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 voidanix <voidanix@FreeBSD.org>
+ */
+
+#include <sys/param.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <libgeom.h>
+#include <geom/zoned/g_zoned.h>
+
+#include "core/geom.h"
+#include "misc/subr.h"
+
+uint32_t lib_version = G_LIB_VERSION;
+uint32_t version = G_ZONED_VERSION;
+
+#define GZONED_ZONESIZE		"256M"
+
+static void zoned_main(struct gctl_req *req, unsigned flags);
+static void zoned_create(struct gctl_req *req);
+static void zoned_clear(struct gctl_req *req);
+
+struct g_command class_commands[] = {
+	{ "clear", G_FLAG_VERBOSE, zoned_main, G_NULL_OPTS, "[-v] dev ..." },
+	{ "create", G_FLAG_VERBOSE | G_FLAG_LOADKLD, zoned_main,
+	    {
+		{ 's', "zonesize", GZONED_ZONESIZE, G_TYPE_NUMBER },
+		{ 'c', "conventional", "", G_TYPE_STRING },
+		{ 'm', "maxopen", "0", G_TYPE_NUMBER },
+		{ 'u', "restricted", NULL, G_TYPE_BOOL },
+		G_OPT_SENTINEL
+	    },
+	    "[-uv] [-s zonesize] [-c zone[-zone][,zone[-zone]]...] "
+	    "[-m maxopen] dev"
+	},
+	{ "fault", G_FLAG_VERBOSE, NULL,
+	    {
+		{ 'z', "zone", NULL, G_TYPE_NUMBER },
+		{ 's', "state", NULL, G_TYPE_STRING },
+		G_OPT_SENTINEL
+	    },
+	    "[-v] -z zone -s ro|offline|reset|clear name"
+	},
+	{ "destroy", G_FLAG_VERBOSE, NULL,
+		{
+			{ 'f', "force", NULL, G_TYPE_BOOL },
+			G_OPT_SENTINEL
+		},
+		"[-fv] name ..." },
+	{ "stop", G_FLAG_VERBOSE, NULL,
+		{
+			{ 'f', "force", NULL, G_TYPE_BOOL },
+			G_OPT_SENTINEL
+		},
+		"[-fv] name ..." },
+	G_CMD_SENTINEL
+};
+
+static int verbose = 0;
+
+static void
+zoned_main(struct gctl_req *req, unsigned flags)
+{
+	const char *name;
+
+	if ((flags & G_FLAG_VERBOSE) != 0)
+		verbose = 1;
+
+	name = gctl_get_ascii(req, "verb");
+	if (name == NULL) {
+		gctl_error(req, "No '%s' argument.", "verb");
+		return;
+	}
+	if (strcmp(name, "create") == 0)
+		zoned_create(req);
+	else if (strcmp(name, "clear") == 0)
+		zoned_clear(req);
+	else
+		gctl_error(req, "Unknown command: %s.", name);
+}
+
+/*
+ * Parse a conventional-zone specification into md_conv/md_nconv.
+ */
+static int
+zoned_parse_conv(struct gctl_req *req, const char *spec, uint32_t nzones,
+    struct g_zoned_metadata *md)
+{
+	char *buf, *ep, *sp, *tok;
+	unsigned long first, last;
+	uint32_t i;
+
+	md->md_nconv = 0;
+	if (*spec == '\0')
+		return (0);
+	buf = strdup(spec);
+	if (buf == NULL) {
+		gctl_error(req, "Cannot allocate memory.");
+		return (-1);
+	}
+	for (tok = strtok_r(buf, ",", &sp); tok != NULL;
+	    tok = strtok_r(NULL, ",", &sp)) {
+		if (md->md_nconv == G_ZONED_MAXCONV) {
+			gctl_error(req,
+			    "Too many conventional zone ranges (max %d).",
+			    G_ZONED_MAXCONV);
+			goto fail;
+		}
+		errno = 0;
+		if (!isdigit((unsigned char)*tok))
+			goto syntax;
+		first = strtoul(tok, &ep, 10);
+		if (ep == tok || errno != 0)
+			goto syntax;
+		if (*ep == '-') {
+			tok = ep + 1;
+			if (!isdigit((unsigned char)*tok))
+				goto syntax;
+			last = strtoul(tok, &ep, 10);
+			if (ep == tok || *ep != '\0' || errno != 0)
+				goto syntax;
+		} else if (*ep == '\0') {
+			last = first;
+		} else {
+			goto syntax;
+		}
+		if (last < first) {
+			gctl_error(req, "Backwards zone range %lu-%lu.",
+			    first, last);
+			goto fail;
+		}
+		if (last >= nzones) {
+			gctl_error(req,
+			    "Zone %lu does not exist (zones 0-%u).", last,
+			    nzones - 1);
+			goto fail;
+		}
+		for (i = 0; i < md->md_nconv; i++) {
+			uint32_t ofirst, olast;
+
+			ofirst = md->md_conv[i].cr_first;
+			olast = ofirst + md->md_conv[i].cr_count - 1;
+			if (first <= olast && ofirst <= last) {
+				gctl_error(req, "Zone range %lu-%lu overlaps "
+				    "%u-%u.", first, last, ofirst, olast);
+				goto fail;
+			}
+		}
+		md->md_conv[md->md_nconv].cr_first = first;
+		md->md_conv[md->md_nconv].cr_count = last - first + 1;
+		md->md_nconv++;
+	}
+	free(buf);
+	return (0);
+syntax:
+	gctl_error(req, "Invalid conventional zone specification '%s'.",
+	    spec);
+fail:
+	free(buf);
+	return (-1);
+}
+
+static void
+zoned_create(struct gctl_req *req)
+{
+	struct g_zoned_metadata md;
+	u_char sector[512];
+	const char *conv, *dev;
+	off_t msize, zonesize;
+	intmax_t maxopen;
+	unsigned int secsize;
+	uint32_t i, nconv, nzones;
+	int nargs;
+
+	bzero(sector, sizeof(sector));
+	bzero(&md, sizeof(md));
+	nargs = gctl_get_int(req, "nargs");
+	if (nargs != 1) {
+		gctl_error(req,
+		    "Usage: create [-u] [-s zonesize] [-c zones] "
+		    "[-m maxopen] dev");
+		return;
+	}
+	zonesize = (off_t)gctl_get_intmax(req, "zonesize");
+	conv = gctl_get_ascii(req, "conventional");
+	dev = gctl_get_ascii(req, "arg0");
+
+	msize = g_get_mediasize(dev);
+	secsize = g_get_sectorsize(dev);
+	if (msize == 0 || secsize == 0) {
+		gctl_error(req, "Can't get information about %s: %s.", dev,
+		    strerror(errno));
+		return;
+	}
+	if (g_provider_is_host_managed(dev)) {
+		gctl_error(req, "Cannot create a zoned device on %s: the tail "
+		    "of a host-managed zoned device cannot hold the metadata "
+		    "and zone state table.", dev);
+		return;
+	}
+	if (zonesize <= 0 || (zonesize % secsize) != 0) {
+		gctl_error(req, "Zone size must be a positive multiple of %u.",
+		    secsize);
+		return;
+	}
+	if ((uint64_t)zonesize / secsize > G_ZONED_MAXZONESECS) {
+		gctl_error(req, "Zone size %jd exceeds %u sectors.",
+		    (intmax_t)zonesize, G_ZONED_MAXZONESECS);
+		return;
+	}
+	nzones = g_zoned_nzones(msize, zonesize, secsize);
+	if (nzones == 0) {
+		gctl_error(req, "Zone size %jd is unusable for %s: it must "
+		    "yield at least one zone and at most %u.",
+		    (intmax_t)zonesize, dev, G_ZONED_MAXZONES);
+		return;
+	}
+	if (zoned_parse_conv(req, conv, nzones, &md) != 0)
+		return;
+	maxopen = gctl_get_intmax(req, "maxopen");
+	if (maxopen < 0 || maxopen > nzones) {
+		gctl_error(req, "Maximum open zones must be between 0 and "
+		    "the number of zones (%u).", nzones);
+		return;
+	}
+
+	memcpy(md.md_magic, G_ZONED_MAGIC, sizeof(G_ZONED_MAGIC));
+	md.md_version = G_ZONED_VERSION;
+	md.md_id = arc4random();
+	md.md_zonesize = zonesize;
+	md.md_sectorsize = secsize;
+	md.md_provsize = msize;
+	md.md_maxopen = (uint32_t)maxopen;
+	if (gctl_get_int(req, "restricted"))
+		md.md_flags |= G_ZONED_MD_RESTRICTED_READS;
+
+	/*
+	 * Hand the geometry to the kernel rather than writing it on the media
+	 * and waiting for the taste.  The metadata should be written only once
+	 * the device is up: a request the kernel turns down leaves the provider
+	 * untouched.
+	 */
+	zoned_metadata_encode(&md, sector);
+	gctl_ro_param(req, "metadata", sizeof(sector), sector);
+	if (gctl_issue(req) != NULL)
+		return;
+	if (verbose) {
+		nconv = 0;
+		for (i = 0; i < md.md_nconv; i++)
+			nconv += md.md_conv[i].cr_count;
+		printf("Device %s zoned: %u zones of %jd bytes"
+		    " (%u conventional).\n", dev, nzones, (intmax_t)zonesize,
+		    nconv);
+	}
+}
+
+static void
+zoned_clear(struct gctl_req *req)
+{
+	const char *name;
+	int error, i, nargs;
+
+	nargs = gctl_get_int(req, "nargs");
+	if (nargs < 1) {
+		gctl_error(req, "Missing device(s).");
+		return;
+	}
+
+	for (i = 0; i < nargs; i++) {
+		name = gctl_get_ascii(req, "arg%d", i);
+		error = g_metadata_clear(name, G_ZONED_MAGIC);
+		if (error != 0) {
+			fprintf(stderr, "Can't clear metadata on %s: %s.\n",
+			    name, strerror(error));
+			gctl_error(req, "Not fully done.");
+			continue;
+		}
+		if (verbose)
+			printf("Metadata cleared on %s.\n", name);
+	}
+}
diff --git a/lib/geom/zoned/gzoned.8 b/lib/geom/zoned/gzoned.8
new file mode 100644
index 000000000000..854eeed74854
--- /dev/null
+++ b/lib/geom/zoned/gzoned.8
@@ -0,0 +1,260 @@
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.\" Copyright (c) 2026 voidanix <voidanix@FreeBSD.org>
+.\"
+.Dd August 4, 2026
+.Dt GZONED 8
+.Os
+.Sh NAME
+.Nm gzoned
+.Nd "emulate a host-managed zoned device on top of a regular provider"
+.Sh SYNOPSIS
+.Nm
+.Cm create
+.Op Fl uv
+.Op Fl s Ar zonesize
+.Op Fl c Ar zones
+.Op Fl m Ar maxopen
+.Ar dev
+.Nm
+.Cm fault
+.Op Fl v
+.Fl z Ar zone
+.Fl s Ar state
+.Ar name
+.Nm
+.Cm destroy
+.Op Fl fv
+.Ar name ...
+.Nm
+.Cm stop
+.Op Fl fv
+.Ar name ...
+.Nm
+.Cm clear
+.Op Fl v
+.Ar dev ...
+.Nm
+.Cm list
+.Nm
+.Cm status
+.Nm
+.Cm load
+.Nm
+.Cm unload
+.Sh DESCRIPTION
+The
+.Nm
+utility presents to the system a host-managed zoned block device backed by a
+regular, non-zoned provider.
+The exposed device is of the kind described by the
+SCSI Zoned Block Commands (ZBC) and ATA Zoned ATA Command Set (ZAC)
+specifications.
+.Pp
+The backing provider is divided into equally sized, sequential-write-required
+zones, unless marked conventional by the
+.Fl c
+option.
+For each zone the GEOM class maintains a write pointer and answers the
+zone-management commands
+.Pq see Xr zonectl 8
+issued through the
+.Dv DIOCZONECMD
+ioctl.
+.Pp
+Resetting a write pointer issues a best-effort
+.Dv BIO_DELETE
+for the zone's range to the backing provider, resembling a real drives
+de-allocating a reset zone.
+.Dv BIO_DELETE
+requests to the zoned device itself are rejected, as zoned devices have no
+unmap.
+Sequential zones are reclaimed by resetting the write pointer.
+.Pp
+The configuration for a device is persistent.
+A metadata block and the per-zone state are stored in a small area reserved at
+the tail of the backing provider, so the zoned device is recreated
+automatically whenever the provider appears
+.Pq at boot, module load or hot-plug ,
+with its write pointers restored.
+Note that because of the metadata location on the backing provider, the zoned
+device is torn down if the backing provider is resized with unflushed zone state
+changes being discarded.
+Zone state changes are committed to the backing provider on a BIO_FLUSH.
+Changes made since the last flush may be rolled back by an unclean shutdown,
+resembling a drive whose zone state is volatile until flushed.
+.Pp
+The first argument to
+.Nm
+indicates an action to be performed:
+.Bl -tag -width ".Cm destroy"
+.It Cm create
+Mark
+.Ar dev
+as zoned, creating a device named after the backing provider with
+.Dq .zoned
+appended
+.Pq e.g. Pa /dev/da0.zoned .
+The number of zones is the usable size of
+.Ar dev
+divided by
+.Ar zonesize ;
+a small reservation at the tail of
+.Ar dev
+holds the zone metadata; any trailing space that does not make up a full zone
+is not exposed.
+Host-managed zoned providers are refused: their tail normally lies in a
+sequential-write-required zone and cannot hold the metadata and zone state
+table.
+.It Cm fault
+Inject a zone fault into the running device
+.Ar name .
+.Fl z Ar zone
+selects the zone by number and
+.Fl s Ar state
+is one of
+.Ar ro
+.Pq Ar readonly ,
+.Ar offline ,
+.Ar reset
+.Pq zone needs a write-pointer reset
+or
+.Ar clear
+.Pq return the zone to its normal state implied by its write pointer .
+Readonly and offline zones reject writes and zone-management commands;
+the fault persists with the rest of the zone state until cleared.
+.It Cm clear
+Permanently un-zone the given backing device(s) by clearing the on-disk
+metadata.
+A backing device carrying a live zoned device is held open and cannot be
+cleared: tear the device down with
+.Cm stop
+first.
+.It Cm stop
+Tear down the given zoned device(s) without touching the on-disk metadata.
+.Ar name
+is the zoned device
+.Pq e.g. Pa da0.zoned .
+Unflushed zone state is committed to the backing provider first.
+The device comes back the next time the backing provider is tasted
+.Pq e.g. at boot or when it is opened and closed again .
+.It Cm destroy
+Same as
+.Cm stop .
+.It Cm list
+See
+.Xr geom 8 .
+.It Cm status
+See
+.Xr geom 8 .
+.It Cm load
+See
+.Xr geom 8 .
+.It Cm unload
+See
+.Xr geom 8 .
+.El
+.Pp
+Additional options:
+.Bl -tag -width ".Fl s Ar zonesize"
+.It Fl c Ar zones
+Mark the given zones as conventional
+.Pq randomly writable, no write pointer .
+.Ar zones
+is a comma-separated list of zone numbers and inclusive ranges, counted
+from zero, e.g.\&
+.Ar 60 ,
+.Ar 60-70
+or
+.Ar 0-3,60,90-95 .
+At most 16 comma-separated entries may be given.
+By default all zones are sequential-write-required.
+.It Fl f
+Tear down the device even if it is still open.
+.It Fl m Ar maxopen
+Limit the number of simultaneously open sequential zones.
+Once the limit is reached, implicitly opened zones are implicitly closed to make
+room.
+If every open zone was opened explicitly, further writes and explicit opens fail
+with
+.Er ENOSPC .
+The default of 0 means unlimited.
+.It Fl s Ar zonesize
+Size of a single zone.
+The value must be a multiple of the backing provider's sector size and may use
+the usual size suffixes
+.Pq e.g. Ar 7G , 64M .
+The default is
+.Ar 256M .
+It must also leave the device with at least one, and at most 4294967295 zones.
+.It Fl u
+Restrict reads in sequential-write-required zones
+.Pq unset URSWRZ bit .
+Similarly to real zoned drives, the device supports unrestricted reads that are
+passed through unchecked by default.
+With
+.Fl u
+the device instead reports URSWRZ as unset and rejects with
+.Er EIO
+any read in a sequential zone that extends beyond that zone's write pointer, as
+well as any read that crosses out of a conventional zone into a sequential one.
+Note that this renders the device unreadable to zone-unaware consumers.
+Tasting the zoned provider
+.Pq e.g. partition table scanning
+will also fail until the accessed zones have been written.
+.It Fl v
+Be more verbose.
+.El
+.Sh SYSCTL VARIABLES
+The following
+.Xr sysctl 8
+variable can be used to control the behavior of the
+.Nm ZONED
+GEOM class.
+.Bl -tag -width indent
+.It Va kern.geom.zoned.debug : No 0
+Debug level of the
+.Nm ZONED
+GEOM class.
+This can be set to a number between 0 and 3 inclusive.
+If set to 0, minimal debug information is printed; if set to 3, the maximum
+amount of debug information is printed.
+.El
+.Sh EXIT STATUS
+.Ex -std
+.Sh EXAMPLES
+Expose the memory disk
+.Pa /dev/md0
+as a host-managed zoned device
+.Pa /dev/md0.zoned
+with one-gigabyte zones, the first four of which are conventional.
+Inspect its zones, then permanently un-zone it:
+.Bd -literal -offset indent
+mdconfig -t malloc -s 10G -u md0
+gzoned create -s 1G -c 0-3 /dev/md0
+zonectl -d /dev/md0.zoned -c params
+zonectl -d /dev/md0.zoned -c rz
+gzoned clear /dev/md0
+.Ed
+.Pp
+The same operations are also available through the
+.Xr geom 8
+front end:
+.Bd -literal -offset indent
+geom zoned create -s 128M /dev/da0
+geom zoned stop da0.zoned
+geom zoned clear /dev/da0
+.Ed
+.Sh SEE ALSO
+.Xr geom 4 ,
+.Xr md 4 ,
+.Xr geom 8 ,
+.Xr mdconfig 8 ,
+.Xr zonectl 8
+.Sh HISTORY
+The
+.Nm
+utility appeared in
+.Fx 16.0 .
+.Sh AUTHORS
+.An voidanix Aq Mt voidanix@FreeBSD.org
diff --git a/sbin/geom/core/geom.8 b/sbin/geom/core/geom.8
index 96db7f3a80e4..ad50334d324f 100644
--- a/sbin/geom/core/geom.8
+++ b/sbin/geom/core/geom.8
@@ -145,12 +145,12 @@ are:
 .Pp
 .Bl -column "* MULTIPATH" "* MULTIPATH" "* MULTIPATH" -offset indent
 .It \(bu CACHE Ta \(bu MIRROR Ta \(bu SCHED
-.It \(bu CONCAT Ta \(bu MOUNTVER Ta \(bu SHSEC 
+.It \(bu CONCAT Ta \(bu MOUNTVER Ta \(bu SHSEC
 .It \(bu DISK Ta \(bu MULTIPATH Ta \(bu STRIPE
 .It \(bu ELI Ta \(bu NOP Ta \(bu UNION
 .It \(bu JOURNAL Ta \(bu PART Ta \(bu VFS
 .It \(bu LABEL Ta \(bu RAID Ta \(bu VIRSTOR
-.It \(bu MD Ta \(bu RAID3
+.It \(bu MD Ta \(bu RAID3 Ta \(bu ZONED
 .El
 .Sh ENVIRONMENT
 The following environment variables affect the execution of
@@ -202,7 +202,8 @@ geom md unload
 .Xr gshsec 8 ,
 .Xr gstripe 8 ,
 .Xr gunion 8 ,
-.Xr gvirstor 8
+.Xr gvirstor 8 ,
+.Xr gzoned 8
 .Sh HISTORY
 The
 .Nm
diff --git a/sbin/geom/misc/subr.c b/sbin/geom/misc/subr.c
index 18bac7108ed0..8e5e78258268 100644
--- a/sbin/geom/misc/subr.c
+++ b/sbin/geom/misc/subr.c
@@ -28,7 +28,9 @@
 
 #include <sys/param.h>
 #include <sys/disk.h>
+#include <sys/disk_zone.h>
 #include <sys/endian.h>
+#include <sys/ioctl.h>
 #include <sys/uio.h>
 #include <errno.h>
 #include <fcntl.h>
@@ -218,6 +220,24 @@ g_get_sectorsize(const char *name)
 	return ((unsigned int)sectorsize);
 }
 
+int
+g_provider_is_host_managed(const char *name)
+{
+	struct disk_zone_args zone_args;
+	int error, fd;
+
+	bzero(&zone_args, sizeof(zone_args));
+	zone_args.zone_cmd = DISK_ZONE_GET_PARAMS;
+	fd = g_open(name, 0);
+	if (fd == -1)
+		return (0);
+	error = ioctl(fd, DIOCZONECMD, &zone_args);
+	(void)g_close(fd);
+	return (error == 0 &&
+	    zone_args.zone_params.disk_params.zone_mode ==
+	    DISK_ZONE_MODE_HOST_MANAGED);
+}
+
 int
 g_metadata_read(const char *name, unsigned char *md, size_t size,
     const char *magic)
diff --git a/sbin/geom/misc/subr.h b/sbin/geom/misc/subr.h
index 708abf5f8767..0090543d1e39 100644
--- a/sbin/geom/misc/subr.h
+++ b/sbin/geom/misc/subr.h
@@ -36,6 +36,7 @@ int g_parse_lba(const char *lbastr, unsigned int sectorsize, off_t *sectors);
 
 off_t g_get_mediasize(const char *name);
 unsigned int g_get_sectorsize(const char *name);
+int g_provider_is_host_managed(const char *name);
 
 int g_metadata_read(const char *name, unsigned char *md, size_t size,
     const char *magic);
diff --git a/sys/geom/zoned/g_zoned.c b/sys/geom/zoned/g_zoned.c
new file mode 100644
index 000000000000..5f9773273551
--- /dev/null
+++ b/sys/geom/zoned/g_zoned.c
@@ -0,0 +1,1819 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 voidanix <voidanix@FreeBSD.org>
+ */
+
+/*
+ * GEOM_ZONED presents a plain (non-zoned) provider to the rest of the system
+ * as a host-managed zoned block device. The medium is divided into equal
+ * sequential-write-required zones. This GEOM class tracks a write pointer per
+ * zone and answers BIO_ZONE management commands similarly to a real ZBC/ZAC
+ * drive would.
+ *
+ * Persistence:
+ *   - The provider's last sector holds a metadata block, written by the
+ *     userland "create" command. The kernel tastes it on every provider
+ *     arrival and re-creates the zoned device automatically.
+ *   - The sectors just before it hold the per-zone state (condition + write
+ *     pointer). That table is read at taste time and rewritten lazily.
+ *     Zone-state changes, zone-management commands included, only mark the
+ *     table dirty; BIO_FLUSH is what commits them, mirroring a drive whose
+ *     zone state is volatile until a cache flush. Changes since the last
+ *     flush may be rolled back by an unclean shutdown.
+ */
+
+#define	EXTERR_CATEGORY	EXTERR_CAT_GEOMZONED
+
+#include <sys/param.h>
+#include <sys/systm.h>
+#include <sys/bio.h>
+#include <sys/disk_zone.h>
+#include <sys/endian.h>
+#include <sys/exterrvar.h>
+#include <sys/kernel.h>
+#include <sys/lock.h>
+#include <sys/malloc.h>
+#include <sys/module.h>
+#include <sys/mutex.h>
+#include <sys/sbuf.h>
+#include <sys/sysctl.h>
+
+#include <geom/geom.h>
+#include <geom/geom_dbg.h>
+#include <geom/zoned/g_zoned.h>
+
+FEATURE(geom_zoned, "GEOM Zoned Storage Medium emulation");
+
+static MALLOC_DEFINE(M_ZONED, "zoned_data", "GEOM_ZONED Data");
+
+SYSCTL_DECL(_kern_geom);
+static SYSCTL_NODE(_kern_geom, OID_AUTO, zoned, CTLFLAG_RW | CTLFLAG_MPSAFE, 0,
+    "GEOM_ZONED stuff");
+static u_int g_zoned_debug = 0;
+SYSCTL_UINT(_kern_geom_zoned, OID_AUTO, debug, CTLFLAG_RW, &g_zoned_debug, 0,
+    "Debug level");
+
+#define	G_ZONED_EXTERR(bp, error, ...)	({				\
+	(bp)->bio_flags |= BIO_EXTERR;					\
+	EXTERROR_KE(&(bp)->bio_exterr, (error), __VA_ARGS__);		\
+})
+
+static g_access_t g_zoned_access;
+static g_ctl_req_t g_zoned_config;
+static g_ctl_destroy_geom_t g_zoned_destroy_geom;
+static g_dumpconf_t g_zoned_dumpconf;
+static g_orphan_t g_zoned_orphan;
+static g_provgone_t g_zoned_providergone;
+static g_resize_t g_zoned_resize;
+static g_start_t g_zoned_start;
+static g_taste_t g_zoned_taste;
+
+static struct g_class g_zoned_class = {
+	.name = G_ZONED_CLASS_NAME,
+	.version = G_VERSION,
+	.ctlreq = g_zoned_config,
+	.destroy_geom = g_zoned_destroy_geom,
+	.taste = g_zoned_taste,
+	.access = g_zoned_access,
+	.dumpconf = g_zoned_dumpconf,
+	.orphan = g_zoned_orphan,
+	.providergone = g_zoned_providergone,
+	.resize = g_zoned_resize,
+	.spoiled = g_zoned_orphan,
+	.start = g_zoned_start,
+};
+
+/*
+ * Index of the zone that contains the given LBA. Caller must have range-checked
+ * the LBA against sc_maxlba.
+ */
+static __inline uint32_t
+g_zoned_zoneno(struct g_zoned_softc *sc, uint64_t lba)
+{
+
+	return ((uint32_t)(lba / sc->sc_zonesecs));
+}
+
+static bool
+g_zoned_is_conv(const struct g_zoned_softc *sc, uint32_t zno)
+{
+	const struct g_zoned_convrange *cr;
+	uint32_t i;
+
+	for (i = 0; i < sc->sc_nconv; i++) {
+		cr = &sc->sc_conv[i];
+		if (zno >= cr->cr_first && zno - cr->cr_first < cr->cr_count)
+			return (true);
+	}
+	return (false);
+}
+
+/*
+ * Extend the dirty zone range to cover zone zno, so the next BIO_FLUSH
+ * writes its table entry out. Must be called with sc_lock held.
+ */
+static void
+g_zoned_mark_dirty(struct g_zoned_softc *sc, uint32_t zno)
+{
+
+	mtx_assert(&sc->sc_lock, MA_OWNED);
+
+	if (!sc->sc_dirty) {
+		sc->sc_dirty = true;
+		sc->sc_dirtylo = sc->sc_dirtyhi = zno;
+	} else {
+		if (zno < sc->sc_dirtylo)
+			sc->sc_dirtylo = zno;
+		if (zno > sc->sc_dirtyhi)
+			sc->sc_dirtyhi = zno;
+	}
+}
+
+static bool
+g_zoned_cond_is_open(uint8_t cond)
+{
+
+	return (cond == DISK_ZONE_COND_IMPLICIT_OPEN ||
+	    cond == DISK_ZONE_COND_EXPLICIT_OPEN);
+}
+
+/*
+ * Put a zone into a new condition, maintaining the count of open
+ * zones. Must be called with sc_lock held.
+ */
+static void
+g_zoned_set_cond(struct g_zoned_softc *sc, struct disk_zone_rep_entry *z,
+    uint8_t cond)
+{
+	bool is_open, want_open;
+
+	mtx_assert(&sc->sc_lock, MA_OWNED);
+
+	is_open = g_zoned_cond_is_open(z->zone_condition);
+	want_open = g_zoned_cond_is_open(cond);
+	if (!is_open && want_open)
+		sc->sc_nopen++;
+	else if (is_open && !want_open)
+		sc->sc_nopen--;
+	z->zone_condition = cond;
+}
+
+/*
+ * Make room to open one more zone under the open-zones limit by implicitly
+ * closing the lowest-numbered implicitly-open zone, similarly to a real drive.
+ */
+static bool
+g_zoned_open_room(struct g_zoned_softc *sc)
+{
+	struct disk_zone_rep_entry *z;
+	uint32_t i;
+
+	mtx_assert(&sc->sc_lock, MA_OWNED);
+
+	if (sc->sc_maxopen == 0 || sc->sc_nopen < sc->sc_maxopen)
+		return (true);
+	for (i = 0; i < sc->sc_nzones; i++) {
+		z = &sc->sc_zones[i];
+		if (z->zone_condition == DISK_ZONE_COND_IMPLICIT_OPEN) {
+			g_zoned_set_cond(sc, z, DISK_ZONE_COND_CLOSED);
+			g_zoned_mark_dirty(sc, i);
+			return (true);
*** 3509 LINES SKIPPED ***