mirror of https://lore.kernel.org/lkml/
 help / color / mirror / Atom feed
* [PATCH v5 00/11] Kernel API Specification Framework
@ 2026-10-08  8:49 Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 01/11] kernel/api: introduce kernel API specification framework Sasha Levin
                   ` (10 more replies)
  0 siblings, 11 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

This series adds machinery for documenting kernel APIs in a machine-readable
form. The kernel promises not to break user space, but the contract of each
interface is written down only as prose in man pages and comments, which tools
cannot check.

Specifications can document parameter types, valid ranges, constraints, and
alignment requirements. They capture return value semantics including success
conditions and error codes with their meaning. Execution context requirements,
capabilities, locking constraints, signal handling behavior, and side effects
can all be formally specified.

These specifications live alongside the code they document and are both
human-readable and machine-parseable. They can be validated at runtime when
CONFIG_KAPI_RUNTIME_CHECKS is enabled, exported via debugfs for userspace
tools, and extracted from either vmlinux or source code.

The intent is for static analyzers, test generators and documentation tools
to consume these specifications. This series adds the framework, the
extraction tool, and specifications for five syscalls.

The implementation includes a core framework with ELF section storage,
kerneldoc integration for inline specification, a debugfs interface for runtime
querying, ftrace tracepoints that report each spec'd syscall's parameters and
return value, and a Rust-based extraction tool (tools/kapi) supporting JSON,
RST, and plain text output formats. Example specifications are provided for the
four fundamental file syscalls (sys_open, sys_close, sys_read, sys_write) and
for sys_madvise. The series also includes a KUnit test suite with 33 tests and
a runtime verification selftest with 31 TAP tests.

The series is also available in the "spec" branch of:

  https://git.kernel.org/pub/scm/linux/kernel/git/sashal/kapi.git

Changes since v4:

- Rebase onto v7.3-rc6: the sys_open spec accepts O_EMPTYPATH, the madvise
  spec follows the zap_vma_range() rename, and tools/kapi declares Rust 1.85
  to match the kernel minimum.

- CONFIG_KAPI_RUNTIME_CHECKS builds on 32-bit and on architectures without
  a syscall wrapper, and depends on X86 || !ARCH_HAS_SYSCALL_WRAPPER since
  other architectures never reach the hook. __SYSCALL_DEFINEx gains small
  hooks instead of a duplicated copy, with the prototypes in
  <linux/kapi_syscall.h>.

- Correct the syscall specs against the implementation (O_CREAT|O_DIRECTORY,
  O_PATH flag masking, EOVERFLOW and RLIMIT_FSIZE handling, SUID/SGID
  clearing, close() error sources, lock descriptions, madvise locking).

- kdoc_apispec.py: keep paragraph and line structure in long-desc, notes and
  examples, stop truncating fields at colons or 512 characters, and emit
  state-transition conditions and exact success values.

- debugfs exports the complete spec as JSON without a fixed-size buffer.
  tools/kapi gives the same result from --vmlinux and --debugfs, and from
  --source wherever no preprocessor evaluation is needed.

- kernel-doc recognises the KAPI sections only in -apispec mode, so its
  other output is unchanged. Kbuild generates headers only for sources
  with a contexts: line plus another KAPI section, regenerates them when
  the generator changes, and no longer reads stdin when a directory has
  no such sources.

- Fix KAPI_CAP_ALTERNATIVE and add KAPI_SIGNAL_MASK_COUNT.

Changes since v3:

- Fix the build with CONFIG_KAPI_SPEC=y: scripts/Makefile.build now derives the
  list of instrumented sources from $(real-obj-y)/$(real-obj-m) instead of a
  recursive find, and the detection regex matches "contexts:" (the token the
  syscalls actually use). The previous "context-flags:"-only match found no
  files, so no *.apispec.h was generated and the guarded includes failed to
  compile.

- Force-include the generated *.apispec.h via a per-object -include cflag in
  scripts/Makefile.build rather than a manual "#include" at the end of each
  instrumented source; the include blocks are dropped from fs/open.c and
  fs/read_write.c.

- kernel/Makefile: drop the redundant "obj- += api/"; the
  obj-$(CONFIG_KAPI_SPEC) gate already covers 'make clean'.

- kernel/api/Makefile: build kernel_api_spec.o with obj-y now that the subdir
  gate lives in kernel/Makefile.

- kernel/api/Kconfig: drop the tautological "default n" from KAPI_SPEC,
  KAPI_RUNTIME_CHECKS and KAPI_SPEC_DEBUGFS.

- Add an API specification for sys_madvise.

- Add ftrace tracepoints on the runtime-check path: kapi_syscall_enter (spec
  name and named parameter values) and kapi_syscall_exit (return value and
  spec match).

Changes since v2:

- Replace statically sized arrays in the spec structs with const char *
  pointers to reduce memory footprint and remove string truncation.

- Simplify the kerneldoc DSL to short tokens (e.g. `type: uint, input`,
  `contexts: process, softirq`, `constraint-type: range(0, KMAX)`,
  `side-effect: alloc_memory`) in place of raw KAPI_* enum names.

- tools/kapi: commit Cargo.lock for reproducible offline builds, add a
  Makefile and README, expand the kerneldoc parser to cover the full
  DSL, and refactor the vmlinux extractor for the const-pointer layout.

- tools/lib/python/kdoc/kdoc_apispec.py expanded to match the DSL so
  scripts/kernel-doc --apispec emits the structure the extractor consumes.

References:

  v4: https://lore.kernel.org/all/20260529233311.1901670-1-sashal@kernel.org/
  v3: https://lore.kernel.org/all/20260424165130.2306833-1-sashal@kernel.org/
  v2: https://lore.kernel.org/all/20260322121026.869758-1-sashal@kernel.org/
  v1: https://lore.kernel.org/all/20260313150928.2637368-1-sashal@kernel.org/
  RFC v5: https://lore.kernel.org/lkml/20251218204239.4159453-1-sashal@kernel.org/
  RFC v4: https://lore.kernel.org/lkml/20250825181434.3340805-1-sashal@kernel.org/
  RFC v3: https://lore.kernel.org/lkml/20250711114248.2288591-1-sashal@kernel.org/
  RFC v2: https://lore.kernel.org/lkml/20250624180742.5795-1-sashal@kernel.org/
  RFC v1: https://lore.kernel.org/lkml/20250614134858.790460-1-sashal@kernel.org/



Sasha Levin (11):
  kernel/api: introduce kernel API specification framework
  kernel/api: enable kerneldoc-based API specifications
  kernel/api: add debugfs interface for kernel API specifications
  tools/kapi: add kernel API specification extraction tool
  kernel/api: add API specification for sys_open
  kernel/api: add API specification for sys_close
  kernel/api: add API specification for sys_read
  kernel/api: add API specification for sys_write
  kernel/api: add runtime verification selftest
  kernel/api: add API specification for sys_madvise
  kernel/api: add syscall enter/exit tracepoints

 .gitignore                                    |    1 +
 Documentation/dev-tools/index.rst             |    1 +
 Documentation/dev-tools/kernel-api-spec.rst   |  648 ++++
 MAINTAINERS                                   |   13 +
 Makefile                                      |    1 +
 arch/x86/include/asm/syscall_wrapper.h        |    3 +-
 fs/open.c                                     |  557 +++
 fs/read_write.c                               |  710 ++++
 include/asm-generic/vmlinux.lds.h             |   14 +
 include/linux/kapi_syscall.h                  |   35 +
 include/linux/kernel_api_spec.h               | 1226 ++++++
 include/linux/syscalls.h                      |   42 +-
 include/trace/events/kapi.h                   |   74 +
 init/Kconfig                                  |    2 +
 kernel/Makefile                               |    1 +
 kernel/api/Kconfig                            |   74 +
 kernel/api/Makefile                           |   13 +
 kernel/api/internal.h                         |   25 +
 kernel/api/kapi_debugfs.c                     |  553 +++
 kernel/api/kapi_kunit.c                       |  720 ++++
 kernel/api/kernel_api_spec.c                  | 1470 ++++++++
 mm/madvise.c                                  |  549 +++
 scripts/Makefile.build                        |   26 +
 tools/docs/kernel-doc                         |   12 +-
 tools/kapi/.gitignore                         |    3 +
 tools/kapi/Cargo.lock                         |  679 ++++
 tools/kapi/Cargo.toml                         |   20 +
 tools/kapi/Makefile                           |   33 +
 tools/kapi/README.md                          |   33 +
 tools/kapi/src/extractor/debugfs.rs           | 1704 +++++++++
 tools/kapi/src/extractor/kerneldoc_parser.rs  | 3335 +++++++++++++++++
 tools/kapi/src/extractor/mod.rs               |  442 +++
 tools/kapi/src/extractor/source_parser.rs     |  531 +++
 .../src/extractor/vmlinux/binary_utils.rs     |  461 +++
 tools/kapi/src/extractor/vmlinux/mod.rs       | 1160 ++++++
 tools/kapi/src/formatter/json.rs              |  659 ++++
 tools/kapi/src/formatter/mod.rs               |  220 ++
 tools/kapi/src/formatter/plain.rs             |  679 ++++
 tools/kapi/src/formatter/rst.rs               |  802 ++++
 tools/kapi/src/main.rs                        |  123 +
 tools/lib/python/kdoc/kdoc_apispec.py         | 1391 +++++++
 tools/lib/python/kdoc/kdoc_files.py           |   12 +-
 tools/lib/python/kdoc/kdoc_parser.py          |  101 +-
 tools/testing/selftests/Makefile              |    1 +
 tools/testing/selftests/kapi/.gitignore       |    2 +
 tools/testing/selftests/kapi/Makefile         |    7 +
 tools/testing/selftests/kapi/kapi_test_util.h |   31 +
 tools/testing/selftests/kapi/test_kapi.c      | 1163 ++++++
 48 files changed, 20350 insertions(+), 12 deletions(-)
 create mode 100644 Documentation/dev-tools/kernel-api-spec.rst
 create mode 100644 include/linux/kapi_syscall.h
 create mode 100644 include/linux/kernel_api_spec.h
 create mode 100644 include/trace/events/kapi.h
 create mode 100644 kernel/api/Kconfig
 create mode 100644 kernel/api/Makefile
 create mode 100644 kernel/api/internal.h
 create mode 100644 kernel/api/kapi_debugfs.c
 create mode 100644 kernel/api/kapi_kunit.c
 create mode 100644 kernel/api/kernel_api_spec.c
 create mode 100644 tools/kapi/.gitignore
 create mode 100644 tools/kapi/Cargo.lock
 create mode 100644 tools/kapi/Cargo.toml
 create mode 100644 tools/kapi/Makefile
 create mode 100644 tools/kapi/README.md
 create mode 100644 tools/kapi/src/extractor/debugfs.rs
 create mode 100644 tools/kapi/src/extractor/kerneldoc_parser.rs
 create mode 100644 tools/kapi/src/extractor/mod.rs
 create mode 100644 tools/kapi/src/extractor/source_parser.rs
 create mode 100644 tools/kapi/src/extractor/vmlinux/binary_utils.rs
 create mode 100644 tools/kapi/src/extractor/vmlinux/mod.rs
 create mode 100644 tools/kapi/src/formatter/json.rs
 create mode 100644 tools/kapi/src/formatter/mod.rs
 create mode 100644 tools/kapi/src/formatter/plain.rs
 create mode 100644 tools/kapi/src/formatter/rst.rs
 create mode 100644 tools/kapi/src/main.rs
 create mode 100644 tools/lib/python/kdoc/kdoc_apispec.py
 create mode 100644 tools/testing/selftests/kapi/.gitignore
 create mode 100644 tools/testing/selftests/kapi/Makefile
 create mode 100644 tools/testing/selftests/kapi/kapi_test_util.h
 create mode 100644 tools/testing/selftests/kapi/test_kapi.c

-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* [PATCH v5 01/11] kernel/api: introduce kernel API specification framework
  2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
@ 2026-10-08  8:49 ` Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 02/11] kernel/api: enable kerneldoc-based API specifications Sasha Levin
                   ` (9 subsequent siblings)
  10 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

Add a framework for formally documenting kernel APIs with inline
specifications. This framework provides:

- Structured API documentation with parameter specifications, return
  values, error conditions, and execution context requirements
- Runtime validation capabilities for debugging
  (CONFIG_KAPI_RUNTIME_CHECKS)
- Support for both internal kernel APIs and system calls

The framework stores specifications in a dedicated ELF section and
provides infrastructure for:
- Runtime querying of API documentation
- Integration with existing SYSCALL_DEFINE macros

A KUnit suite (CONFIG_KAPI_KUNIT_TEST) covers registration, lookup,
parameter and return value validation, and JSON export.

No specifications are added here; subsequent patches add them for
individual syscalls. With CONFIG_KAPI_SPEC=n the SYSCALL_DEFINEx()
expansion is unchanged. CONFIG_KAPI_RUNTIME_CHECKS is a debug option:
a syscall whose arguments violate its specification returns -EINVAL
without running.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 Documentation/dev-tools/index.rst           |    1 +
 Documentation/dev-tools/kernel-api-spec.rst |  615 ++++++++
 MAINTAINERS                                 |   11 +
 arch/x86/include/asm/syscall_wrapper.h      |    3 +-
 include/asm-generic/vmlinux.lds.h           |   14 +
 include/linux/kapi_syscall.h                |   35 +
 include/linux/kernel_api_spec.h             | 1226 ++++++++++++++++
 include/linux/syscalls.h                    |   42 +-
 init/Kconfig                                |    2 +
 kernel/Makefile                             |    1 +
 kernel/api/Kconfig                          |   54 +
 kernel/api/Makefile                         |   10 +
 kernel/api/internal.h                       |   25 +
 kernel/api/kapi_kunit.c                     |  720 ++++++++++
 kernel/api/kernel_api_spec.c                | 1415 +++++++++++++++++++
 15 files changed, 4172 insertions(+), 2 deletions(-)
 create mode 100644 Documentation/dev-tools/kernel-api-spec.rst
 create mode 100644 include/linux/kapi_syscall.h
 create mode 100644 include/linux/kernel_api_spec.h
 create mode 100644 kernel/api/Kconfig
 create mode 100644 kernel/api/Makefile
 create mode 100644 kernel/api/internal.h
 create mode 100644 kernel/api/kapi_kunit.c
 create mode 100644 kernel/api/kernel_api_spec.c

diff --git a/Documentation/dev-tools/index.rst b/Documentation/dev-tools/index.rst
index 59cbb77b33ff4..8d3768645d96c 100644
--- a/Documentation/dev-tools/index.rst
+++ b/Documentation/dev-tools/index.rst
@@ -36,6 +36,7 @@ Documentation/process/debugging/index.rst
    kunit/index
    ktap
    checkuapi
+   kernel-api-spec
    gpio-sloppy-logic-analyzer
    autofdo
    propeller
diff --git a/Documentation/dev-tools/kernel-api-spec.rst b/Documentation/dev-tools/kernel-api-spec.rst
new file mode 100644
index 0000000000000..4fb412b2720d3
--- /dev/null
+++ b/Documentation/dev-tools/kernel-api-spec.rst
@@ -0,0 +1,615 @@
+.. SPDX-License-Identifier: GPL-2.0
+
+======================================
+Kernel API Specification Framework
+======================================
+
+:Author: Sasha Levin <sashal@kernel.org>
+
+.. contents:: Table of Contents
+   :depth: 3
+   :local:
+
+Introduction
+============
+
+The Kernel API Specification Framework (KAPI) describes kernel APIs in a
+machine-readable form. The descriptions are written as kerneldoc annotations
+next to the implementation and compiled into the kernel. They can be used to
+check system call arguments at runtime and can be read back through debugfs or
+the ``kapi`` tool.
+
+Purpose and Goals
+-----------------
+
+The framework aims to:
+
+1. **Improve API Documentation**: Provide structured, inline documentation that
+   lives alongside the code and is maintained as part of the development process.
+
+2. **Enable Runtime Validation**: Optionally validate API usage at runtime to catch
+   common programming errors during development and testing.
+
+3. **Support Tooling**: Export API specifications in machine-readable formats for
+   use by static analyzers, documentation generators, and development tools.
+
+4. **Formalize Contracts**: Explicitly document API contracts including parameter
+   constraints, execution contexts, locking requirements, and side effects.
+
+Architecture Overview
+=====================
+
+Components
+----------
+
+The framework consists of several key components:
+
+1. **Core Framework** (``kernel/api/kernel_api_spec.c``)
+
+   - API specification registration and storage
+   - Runtime validation engine
+   - Specification lookup and querying
+
+2. **DebugFS Interface** (``kernel/api/kapi_debugfs.c``)
+
+   - Runtime introspection via ``/sys/kernel/debug/kapi/``
+   - Per-API detailed specification output
+   - List of all registered API specifications
+
+3. **kapi Tool** (``tools/kapi/``)
+
+   - Userspace utility for extracting specifications
+   - Multiple input sources (source, binary, debugfs)
+   - Multiple output formats (plain, JSON, RST)
+   - Testing and validation utilities
+
+Data Model
+----------
+
+The framework uses a hierarchical data model::
+
+    kernel_api_spec
+    ├── Basic Information
+    │   ├── name (API function name)
+    │   ├── version (specification version)
+    │   └── description (human-readable description)
+    │
+    ├── Parameters (up to 16)
+    │   └── kapi_param_spec
+    │       ├── name
+    │       ├── type (int, pointer, fd, path, etc.)
+    │       ├── flags (in, out, inout, optional, etc.)
+    │       ├── constraints (range, mask, enum values)
+    │       └── description
+    │
+    ├── Return Value
+    │   └── kapi_return_spec
+    │       ├── type
+    │       ├── success conditions
+    │       └── validation rules
+    │
+    ├── Error Conditions (up to 32)
+    │   └── kapi_error_spec
+    │       ├── error code
+    │       ├── name
+    │       ├── condition
+    │       └── description
+    │
+    ├── Execution Context
+    │   ├── allowed contexts (process, interrupt, etc.)
+    │   ├── locking requirements
+    │   └── preemption/interrupt state
+    │
+    └── Side Effects
+        ├── memory allocation
+        ├── state changes
+        └── signal handling
+
+Usage Guide
+===========
+
+Basic API Specification
+-----------------------
+
+API specifications are written as KAPI-annotated kerneldoc comments directly in
+the source file, immediately preceding the function implementation. With
+``CONFIG_KAPI_SPEC`` enabled, Kbuild runs ``kernel-doc -apispec`` on each
+built-in C file that has a ``contexts:`` (or ``context-flags:``) line plus at
+least one of ``api-type:``, ``param:``, ``error:``, ``capability:``,
+``signal:``, ``lock:``, ``state-trans:``, ``constraint:``, ``side-effect:`` or
+``long-desc:``, and compiles the generated header into that file. The ``kapi``
+tool reads the same annotations, or the specifications in a built kernel, for
+use outside the kernel build.
+
+The following is an excerpt of the ``sys_read`` specification in
+``fs/read_write.c``, trimmed for length:
+
+.. code-block:: c
+
+    /**
+     * sys_read - Read data from a file descriptor
+     * @fd: File descriptor to read from
+     * @buf: User-space buffer to read data into
+     * @count: Maximum number of bytes to read
+     *
+     * long-desc: Attempts to read up to count bytes from file descriptor fd into
+     *   the buffer starting at buf. ...
+     *
+     * contexts: process, sleepable
+     *
+     * param: fd
+     *   type: fd, input
+     *   constraint-type: range(0, INT_MAX)
+     *
+     * param: buf
+     *   type: user_ptr, output
+     *   constraint-type: buffer(2)
+     *
+     * param: count
+     *   type: uint, input
+     *
+     * return:
+     *   type: int
+     *   check-type: range
+     *   success: >= 0
+     *   desc: On success, returns the number of bytes read (non-negative). ...
+     *
+     * error: EBADF, Bad file descriptor
+     *   desc: fd is not a valid file descriptor, or fd was not opened for
+     *     reading. ...
+     */
+    SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
+
+DSL reference:
+
+* ``contexts:`` — comma-separated list of call contexts.  Accepted tokens:
+  ``process``, ``softirq``, ``hardirq``, ``nmi``, ``atomic``, ``sleepable``,
+  ``preempt_disabled``, ``irq_disabled``.  ``context-flags:`` with
+  ``|``-joined ``KAPI_CTX_*`` constants is equivalent.
+* ``type:`` — parameter type plus direction/qualifier flags on a single
+  line.  Type aliases (case-insensitive): ``int``, ``uint``, ``ptr``,
+  ``fd``, ``path``, ``user_ptr`` (or ``uptr``), ``struct``, ``union``,
+  ``enum``, ``func_ptr``, ``array``, ``custom``.  Flag aliases:
+  ``input``, ``output``, ``inout``, ``user``, ``optional``, ``const``,
+  ``volatile``, ``dma``, ``aligned``.
+* ``constraint-type:`` — a ``KAPI_CONSTRAINT_*`` enum token or a
+  function-call expression.  ``range(lo, hi)``, ``mask(expr)``,
+  ``enum(v1, v2, …)``, ``buffer(size_param_idx)``, ``alignment(N)``,
+  ``user_string``, ``user_path``, ``user_ptr``, ``power_of_two``,
+  ``page_aligned``, ``nonzero``.  ``user_string`` takes its length limits
+  from ``range:``.  The function-call
+  form populates the matching aux fields
+  (``range:`` / ``valid-mask:`` / ``size-param:``).
+* ``lock: … type:`` accepts ``mutex``, ``spinlock``, ``rwlock``,
+  ``seqlock``, ``rcu``, ``semaphore``, ``custom`` or ``KAPI_LOCK_*``.
+* ``signal: … direction:`` accepts ``receive``, ``send``, ``handle``,
+  ``block``, ``ignore`` (bitmask, joinable with ``|`` or ``,``).
+* ``signal: … action:`` accepts ``default``, ``terminate``, ``coredump``,
+  ``stop``, ``continue``, ``custom``, ``return``, ``restart``,
+  ``queue``, ``discard``, ``transform``.
+* ``signal: … timing:`` accepts ``before``, ``during``, ``after``.
+* ``capability: … type:`` accepts ``bypass_check``, ``increase_limit``,
+  ``override_restriction``, ``grant_permission``, ``modify_behavior``,
+  ``access_resource``, ``perform_operation``.
+* ``side-effect:`` accepts the snake_case effect names
+  (``alloc_memory``, ``free_memory``, ``modify_state``, ``signal_send``,
+  ``file_position``, ``lock_acquire``, ``lock_release``,
+  ``resource_create``, ``resource_destroy``, ``schedule``, ``hardware``,
+  ``network``, ``filesystem``, ``process_state``, ``irreversible``)
+  joined with ``|`` — for example ``side-effect: resource_create | alloc_memory``.
+* ``return: … type:`` reuses the ``type:`` aliases above.
+* ``return: … check-type:`` accepts ``exact``, ``range``,
+  ``fd``, ``no_return``.  ``success:``
+  gives the success value for ``exact`` (an integer, optionally
+  written ``= N``) and the lower bound for ``range`` (``>= N``); the
+  other check types do not use it.  The ``type:`` of a return block
+  is also kept as written (for example ``int``) in the
+  human-readable ``type_name`` of ``struct kapi_return_spec``.
+* ``error:`` takes a ``NAME, one-line summary`` header followed
+  by optional indented ``desc:`` / ``condition:`` subfields.
+* ``lock:`` and ``signal:`` take an indented ``desc:`` subfield
+  for the long-form description; ``signal:`` also accepts
+  ``number:`` (the signal constant, for example ``SIGPIPE``),
+  ``errno:``, ``priority:``, ``restartable:``, ``interruptible:``,
+  and ``queue:`` subfields.
+* ``state-trans:`` takes ``from:``, ``to:``, ``object:``,
+  optional ``condition:``, and ``desc:`` subfields.  The condition is
+  stored in its own field, apart from the description.
+* ``long-desc:`` is a free-form multi-line prose block that
+  populates ``long_description`` in the spec.  ``notes:`` is a
+  free-form block of the same kind.  In both, a blank line starts a
+  new paragraph, a line starting with ``- `` stays on its own line so
+  bullet lists survive, and any other wrapped line is joined to the
+  previous one with a space.  ``examples:`` keeps one example per
+  line, preserving the relative indentation of nested code.  The
+  line breaks are stored as ``\n`` in the generated strings, which
+  have no length limit.
+* A subfield line inside a block starts with one of the subfield
+  names of that block followed by ``:``.  Every other line, even one
+  with a colon in the middle of a sentence, continues the previous
+  subfield.
+* ``param-count:`` is optional; the parser counts ``param:`` blocks and
+  warns when an explicit count disagrees.
+
+System Call Specification
+-------------------------
+
+System calls are documented inline in the implementation file (e.g., ``fs/open.c``)
+using KAPI-annotated kerneldoc comments. When ``CONFIG_KAPI_RUNTIME_CHECKS`` is
+enabled, the ``SYSCALL_DEFINEx`` macros automatically look up the specification
+and validate parameters before and after the syscall executes.
+
+Runtime Validation
+==================
+
+Enabling Validation
+-------------------
+
+Runtime validation is controlled by kernel configuration:
+
+1. Enable ``CONFIG_KAPI_SPEC`` to build the framework
+2. Enable ``CONFIG_KAPI_RUNTIME_CHECKS`` for runtime validation
+
+Validation Behavior
+-------------------
+
+When ``CONFIG_KAPI_RUNTIME_CHECKS`` is enabled, every system call that has a
+specification is validated in its ``SYSCALL_DEFINEx()`` wrapper: the arguments
+are checked against the parameter constraints before the handler runs, and the
+return value is checked against the return specification afterwards. Violations
+are reported via ``pr_warn_ratelimited`` to avoid flooding the kernel log. On
+the return side only a successful ``fd`` return that is not a valid file
+descriptor is reported. Any other value that does not satisfy the success check
+is treated as an error and accepted, and error codes that the specification
+does not list are only logged at debug level.
+The execution context recorded in a specification is not checked at runtime.
+The option is available on x86 and on architectures that use the generic
+``__SYSCALL_DEFINEx()``.
+
+Custom Validators
+-----------------
+
+``KAPI_CONSTRAINT_CUSTOM`` calls the ``validate`` function of the parameter
+specification. Kerneldoc annotations cannot set it, so it is only available to a
+``struct kapi_param_spec`` that is filled in by hand:
+
+.. code-block:: c
+
+    static bool validate_buffer_size(s64 value)
+    {
+        size_t size = (size_t)value;
+
+        return size > 0 && size <= MAX_BUFFER_SIZE;
+    }
+
+    /* In the parameter definition: */
+    .constraint_type = KAPI_CONSTRAINT_CUSTOM,
+    .validate = validate_buffer_size,
+
+Performance Considerations
+==========================
+
+Memory Overhead
+---------------
+
+Each compiled spec is 26400 bytes (``readelf -sW vmlinux | grep
+__kapi_spec_``), dominated by the fixed-bound arrays
+``struct_specs[8]`` (11584 bytes), ``signal_masks[32]`` (4864),
+``signals[32]`` (3328) and ``params[16]`` (1920). With
+the five syscall specs in this series, ``.kapi_specs`` and the backing
+``.rodata`` objects occupy ~132 KB. Building with ``CONFIG_KAPI_SPEC=n``
+emits no code or data from the framework.
+
+Runtime Overhead
+----------------
+
+When ``CONFIG_KAPI_RUNTIME_CHECKS`` is enabled, each validated
+call pays for a parameter walk plus the per-constraint check
+(range/mask/enum/align/user-ptr/user-path/user-string).
+The cost depends on the parameter count and the constraints involved;
+profile before enabling on workloads where syscall latency matters.
+``CONFIG_KAPI_RUNTIME_CHECKS=n`` compiles the validators away
+entirely.
+
+The kapi Tool
+=============
+
+Overview
+--------
+
+The ``kapi`` tool is a userspace utility that extracts and displays kernel API
+specifications from multiple sources. It provides a unified interface to access
+API documentation whether from compiled kernels, source code, or runtime systems.
+
+Installation
+------------
+
+Build the tool from the kernel source tree::
+
+    $ cd tools/kapi
+    $ cargo build --release
+
+    # Optional: Install system-wide
+    $ cargo install --path .
+
+The tool requires Rust and Cargo to build. The binary will be available at
+``tools/kapi/target/release/kapi``.
+
+Command-Line Usage
+------------------
+
+Basic syntax::
+
+    kapi [OPTIONS] [API_NAME]
+
+Options:
+
+- ``--vmlinux <PATH>``: Extract from compiled kernel binary
+- ``--source <PATH>``: Extract from kernel source code
+- ``--debugfs <PATH>``: Extract from debugfs (default: /sys/kernel/debug)
+- ``-f, --format <FORMAT>``: Output format (plain, json, rst)
+- ``-h, --help``: Display help information
+- ``-V, --version``: Display version information
+
+Input Modes
+-----------
+
+**1. Source Code Mode**
+
+Extract specifications directly from kernel source::
+
+    # Scan entire kernel source tree
+    $ kapi --source /path/to/linux
+
+    # Extract from specific file
+    $ kapi --source fs/open.c
+
+    # Get details for specific API
+    $ kapi --source /path/to/linux sys_close
+
+**2. Vmlinux Mode**
+
+Extract from compiled kernel with debug symbols::
+
+    # List all APIs in vmlinux
+    $ kapi --vmlinux ./vmlinux
+
+    # Get specific syscall details
+    $ kapi --vmlinux ./vmlinux sys_read
+
+**3. Debugfs Mode**
+
+Extract from running kernel via debugfs::
+
+    # Use default debugfs path
+    $ kapi
+
+    # Use custom debugfs mount
+    $ kapi --debugfs /mnt/debugfs
+
+    # Get specific API from running kernel
+    $ kapi sys_write
+
+Output Formats
+--------------
+
+The samples below are shortened; ``...`` marks omitted output.
+
+**Plain Text Format** (default)::
+
+    $ kapi --source . sys_read
+
+    Detailed information for sys_read:
+    ==================================
+    Description: Read data from a file descriptor
+
+    Detailed Description:
+      Attempts to read up to count bytes from file descriptor fd into the buffer starting at buf. ...
+
+    Execution Context:
+      - KAPI_CTX_PROCESS
+      - KAPI_CTX_SLEEPABLE
+
+    Parameters (3):
+      [0] fd (unsigned int fd)
+          File descriptor to read from
+          Flags: IN
+          ...
+
+**JSON Format**::
+
+    $ kapi --source . --format json sys_read
+    {
+      "api_details": {
+        "name": "sys_read",
+        "description": "Read data from a file descriptor",
+        "long_description": "Attempts to read up to count bytes from file descriptor fd into the buffer starting at buf. ...",
+        "context_flags": [
+          "KAPI_CTX_PROCESS",
+          "KAPI_CTX_SLEEPABLE"
+        ],
+        ...
+      }
+    }
+
+**ReStructuredText Format**::
+
+    $ kapi --source . --format rst sys_read
+
+    sys_read
+    ========
+
+    **Read data from a file descriptor**
+
+    Attempts to read up to count bytes from file descriptor fd into the buffer starting at buf. ...
+
+Usage Examples
+--------------
+
+**Generate complete API documentation**::
+
+    # Export all kernel APIs to JSON
+    $ kapi --source /path/to/linux --format json > kernel-apis.json
+
+    # Generate RST documentation for all syscalls
+    $ kapi --vmlinux ./vmlinux --format rst > syscalls.rst
+
+    # List APIs from specific subsystem
+    $ kapi --source fs/
+
+**Integration with other tools**::
+
+    # List the names of all APIs
+    $ kapi --format json | jq -r '.apis[].name'
+
+    # Generate markdown documentation
+    $ kapi --format rst sys_madvise | pandoc -f rst -t markdown
+
+**Debugging and analysis**::
+
+    # Check if specific API exists
+    $ kapi --source . my_custom_api || echo "API not found"
+
+Implementation Details
+----------------------
+
+The tool extracts API specifications from three sources:
+
+1. **Source Code**: Parses KAPI-annotated kerneldoc comments in C files, using
+   the same selection rule as Kbuild; regular expressions only locate the
+   ``SYSCALL_DEFINEx()`` or function that follows each comment
+2. **Vmlinux**: Reads the ``.kapi_specs`` ELF section from compiled kernels
+3. **Debugfs**: Reads from ``/sys/kernel/debug/kapi/`` filesystem interface
+
+The tool supports all KAPI specification types:
+
+- System calls (kerneldoc annotations)
+- Kernel functions (kerneldoc annotations with KAPI tags)
+
+IDE Integration
+---------------
+
+Modern IDEs can use the specification data for:
+
+- Parameter hints
+- Type checking
+- Context validation
+- Error code documentation
+
+Best Practices
+==============
+
+Writing Specifications
+----------------------
+
+1. **Be Comprehensive**: Document all parameters, errors, and side effects
+2. **Keep Updated**: Update specs when API behavior changes
+3. **Use Examples**: Include usage examples in descriptions
+4. **Validate Constraints**: Define realistic constraints for parameters
+5. **Document Context**: Clearly specify allowed execution contexts
+
+Maintenance
+-----------
+
+1. **Version Specifications**: Increment version when API changes
+2. **Deprecation**: Mark deprecated APIs and suggest replacements
+3. **Cross-reference**: Link related APIs in descriptions
+4. **Test Specifications**: Verify specs match implementation
+
+Common Patterns
+---------------
+
+**Optional Parameters**:
+
+.. code-block:: c
+
+    /**
+     * @optional_arg: Optional argument (may be NULL)
+     *
+     * param: optional_arg
+     *   type: KAPI_TYPE_PTR
+     *   flags: KAPI_PARAM_IN | KAPI_PARAM_OPTIONAL
+     */
+
+**Buffer with Size Parameter**:
+
+.. code-block:: c
+
+    /**
+     * @buf: User-space buffer
+     *
+     * param: buf
+     *   type: KAPI_TYPE_USER_PTR
+     *   flags: KAPI_PARAM_OUT | KAPI_PARAM_USER
+     *   constraint-type: KAPI_CONSTRAINT_BUFFER
+     *   size-param: 2
+     */
+
+**Callback Functions**:
+
+.. code-block:: c
+
+    /**
+     * @callback: Callback function
+     *
+     * param: callback
+     *   type: KAPI_TYPE_FUNC_PTR
+     *   flags: KAPI_PARAM_IN
+     */
+
+Troubleshooting
+===============
+
+Common Issues
+-------------
+
+**Specification Not Found**
+
+A syscall that is missing from ``/sys/kernel/debug/kapi/list`` has no
+specification. Ensure the KAPI-annotated kerneldoc comment is in the same
+translation unit as the function implementation, is named ``sys_<name>`` for
+``SYSCALL_DEFINEx(<name>, ...)``, and has a ``contexts:`` line plus one more
+KAPI section as described above.
+
+**Validation Failures**::
+
+    kapi: Parameter fd: invalid file descriptor -1
+
+    Solution: Check parameter constraints or adjust specification if
+    the constraint is incorrect.
+
+Debug Options
+-------------
+
+Violations are logged with ``pr_warn_ratelimited()``. Error codes that a
+specification does not list are logged with ``pr_debug()``; with
+``CONFIG_DYNAMIC_DEBUG`` they can be enabled with::
+
+    echo 'file kernel_api_spec.c +p' > /sys/kernel/debug/dynamic_debug/control
+
+Contributing
+============
+
+Submitting Specifications
+-------------------------
+
+1. Add specifications to the same file as the API implementation
+2. Follow existing patterns and naming conventions
+3. Test with CONFIG_KAPI_RUNTIME_CHECKS enabled
+4. Run scripts/checkpatch.pl on your changes
+
+Review Criteria
+---------------
+
+Specifications will be reviewed for:
+
+1. **Completeness**: All parameters and errors documented
+2. **Accuracy**: Specification matches implementation
+3. **Clarity**: Descriptions are clear and helpful
+4. **Consistency**: Follows framework conventions
+5. **Performance**: No unnecessary runtime overhead
+
+Contact
+-------
+
+- Maintainer: Sasha Levin <sashal@kernel.org>
diff --git a/MAINTAINERS b/MAINTAINERS
index 65e8a4b5c90b1..cd15c6a776ae3 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -14118,6 +14118,17 @@ W:	https://linuxtv.org
 T:	git git://linuxtv.org/media.git
 F:	drivers/media/radio/radio-keene*
 
+KERNEL API SPECIFICATION FRAMEWORK (KAPI)
+M:	Sasha Levin <sashal@kernel.org>
+L:	linux-api@vger.kernel.org
+S:	Maintained
+F:	Documentation/dev-tools/kernel-api-spec.rst
+F:	include/linux/kapi_syscall.h
+F:	include/linux/kernel_api_spec.h
+F:	kernel/api/
+F:	tools/kapi/
+F:	tools/lib/python/kdoc/kdoc_apispec.py
+
 KERNEL AUTOMOUNTER
 M:	Ian Kent <raven@themaw.net>
 L:	autofs@vger.kernel.org
diff --git a/arch/x86/include/asm/syscall_wrapper.h b/arch/x86/include/asm/syscall_wrapper.h
index 7e88705e907f4..54b70c2cac8ac 100644
--- a/arch/x86/include/asm/syscall_wrapper.h
+++ b/arch/x86/include/asm/syscall_wrapper.h
@@ -223,11 +223,12 @@ extern long __ia32_sys_ni_syscall(const struct pt_regs *regs);
 #define __SYSCALL_DEFINEx(x, name, ...)					\
 	static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__));	\
 	static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__));\
+	__KAPI_SYSCALL_DEFINEx(x, name, __VA_ARGS__)			\
 	__X64_SYS_STUBx(x, name, __VA_ARGS__)				\
 	__IA32_SYS_STUBx(x, name, __VA_ARGS__)				\
 	static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__))	\
 	{								\
-		long ret = __do_sys##name(__MAP(x,__SC_CAST,__VA_ARGS__));\
+		long ret = __KAPI_DO_SYS(name)(__MAP(x,__SC_CAST,__VA_ARGS__));\
 		__MAP(x,__SC_TEST,__VA_ARGS__);				\
 		__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__));	\
 		return ret;						\
diff --git a/include/asm-generic/vmlinux.lds.h b/include/asm-generic/vmlinux.lds.h
index b2988aa12f664..b754fa182a6a3 100644
--- a/include/asm-generic/vmlinux.lds.h
+++ b/include/asm-generic/vmlinux.lds.h
@@ -296,6 +296,19 @@
 #define TRACE_SYSCALLS()
 #endif
 
+#ifdef CONFIG_KAPI_SPEC
+/*
+ * .kapi_specs is an array of pointers (see DEFINE_KERNEL_API_SPEC()). Align
+ * __start_kapi_specs to at least pointer alignment so that no padding
+ * separates it from the first entry.
+ */
+#define KAPI_SPECS()							\
+	. = ALIGN(8);							\
+	BOUNDED_SECTION_BY(.kapi_specs, _kapi_specs)
+#else
+#define KAPI_SPECS()
+#endif
+
 #ifdef CONFIG_BPF_EVENTS
 #define BPF_RAW_TP() STRUCT_ALIGN();				\
 	BOUNDED_SECTION_BY(__bpf_raw_tp_map, __bpf_raw_tp)
@@ -485,6 +498,7 @@
 		. = ALIGN(8);						\
 		BOUNDED_SECTION_BY(__tracepoints_ptrs, ___tracepoints_ptrs) \
 		*(__tracepoints_strings)/* Tracepoints: strings */	\
+		KAPI_SPECS()						\
 	}								\
 									\
 	.rodata1          : AT(ADDR(.rodata1) - LOAD_OFFSET) {		\
diff --git a/include/linux/kapi_syscall.h b/include/linux/kapi_syscall.h
new file mode 100644
index 0000000000000..bc9387e0e052c
--- /dev/null
+++ b/include/linux/kapi_syscall.h
@@ -0,0 +1,35 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+/*
+ * Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+ *
+ * Spec lookup and syscall validation entry points of the kernel API
+ * specification framework, kept apart from <linux/kernel_api_spec.h> so that
+ * <linux/syscalls.h> can declare them cheaply.
+ */
+
+#ifndef _LINUX_KAPI_SYSCALL_H
+#define _LINUX_KAPI_SYSCALL_H
+
+#include <linux/types.h>
+
+struct kernel_api_spec;
+
+const struct kernel_api_spec *kapi_get_spec(const char *name);
+
+#ifdef CONFIG_KAPI_RUNTIME_CHECKS
+int kapi_validate_syscall_params(const struct kernel_api_spec *spec,
+				 const s64 *params, int param_count);
+int kapi_validate_syscall_return(const struct kernel_api_spec *spec, s64 retval);
+#else
+static inline int kapi_validate_syscall_params(const struct kernel_api_spec *spec,
+					       const s64 *params, int param_count)
+{
+	return 0;
+}
+static inline int kapi_validate_syscall_return(const struct kernel_api_spec *spec, s64 retval)
+{
+	return 0;
+}
+#endif
+
+#endif /* _LINUX_KAPI_SYSCALL_H */
diff --git a/include/linux/kernel_api_spec.h b/include/linux/kernel_api_spec.h
new file mode 100644
index 0000000000000..f72ef5cd73b38
--- /dev/null
+++ b/include/linux/kernel_api_spec.h
@@ -0,0 +1,1226 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+/*
+ * Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+ *
+ * kernel_api_spec.h - Kernel API Specification Framework
+ *
+ * Structures and macros for specifying kernel APIs in a human and
+ * machine-readable form: parameters, return values, error conditions,
+ * and constraints.
+ */
+
+#ifndef _LINUX_KERNEL_API_SPEC_H
+#define _LINUX_KERNEL_API_SPEC_H
+
+#include <linux/array_size.h>
+#include <linux/bits.h>
+#include <linux/compiler.h>
+#include <linux/errno.h>
+#include <linux/kapi_syscall.h>
+#include <linux/kernel.h>
+#include <linux/stringify.h>
+#include <linux/types.h>
+
+struct sigaction;
+
+#define KAPI_MAX_PARAMS		16
+#define KAPI_MAX_ERRORS		32
+#define KAPI_MAX_CONSTRAINTS	32
+#define KAPI_MAX_LOCKS		16
+#define KAPI_MAX_SIGNALS	32
+#define KAPI_MAX_NAME_LEN	128
+#define KAPI_MAX_DESC_LEN	512
+#define KAPI_MAX_CAPABILITIES	8
+
+/* Magic numbers for section validation (ASCII mnemonics) */
+#define KAPI_MAGIC_PARAMS	0x4B415031	/* 'KAP1' */
+#define KAPI_MAGIC_RETURN	0x4B415232	/* 'KAR2' */
+#define KAPI_MAGIC_ERRORS	0x4B414533	/* 'KAE3' */
+#define KAPI_MAGIC_LOCKS	0x4B414C34	/* 'KAL4' */
+#define KAPI_MAGIC_CONSTRAINTS	0x4B414335	/* 'KAC5' */
+#define KAPI_MAGIC_INFO		0x4B414936	/* 'KAI6' */
+#define KAPI_MAGIC_SIGNALS	0x4B415337	/* 'KAS7' */
+#define KAPI_MAGIC_SIGMASK	0x4B414D38	/* 'KAM8' */
+#define KAPI_MAGIC_STRUCTS	0x4B415439	/* 'KAT9' */
+#define KAPI_MAGIC_EFFECTS	0x4B414641	/* 'KAFA' */
+#define KAPI_MAGIC_TRANS	0x4B415442	/* 'KATB' */
+#define KAPI_MAGIC_CAPS		0x4B414343	/* 'KACC' */
+
+/**
+ * enum kapi_param_type - Parameter type classification
+ * @KAPI_TYPE_VOID: void type
+ * @KAPI_TYPE_INT: Integer types (int, long, etc.)
+ * @KAPI_TYPE_UINT: Unsigned integer types
+ * @KAPI_TYPE_PTR: Pointer types
+ * @KAPI_TYPE_STRUCT: Structure types
+ * @KAPI_TYPE_UNION: Union types
+ * @KAPI_TYPE_ENUM: Enumeration types
+ * @KAPI_TYPE_FUNC_PTR: Function pointer types
+ * @KAPI_TYPE_ARRAY: Array types
+ * @KAPI_TYPE_FD: File descriptor - range-checked only
+ * @KAPI_TYPE_USER_PTR: User space pointer - validated for access and size
+ * @KAPI_TYPE_PATH: Pathname - validated for access and path limits
+ * @KAPI_TYPE_CUSTOM: Custom/complex types
+ */
+enum kapi_param_type {
+	KAPI_TYPE_VOID = 0,
+	KAPI_TYPE_INT,
+	KAPI_TYPE_UINT,
+	KAPI_TYPE_PTR,
+	KAPI_TYPE_STRUCT,
+	KAPI_TYPE_UNION,
+	KAPI_TYPE_ENUM,
+	KAPI_TYPE_FUNC_PTR,
+	KAPI_TYPE_ARRAY,
+	KAPI_TYPE_FD,		/* File descriptor - range-checked only */
+	KAPI_TYPE_USER_PTR,	/* User space pointer - validated for access and size */
+	KAPI_TYPE_PATH,		/* Pathname - validated for access and path limits */
+	KAPI_TYPE_CUSTOM,
+};
+
+/**
+ * enum kapi_param_flags - Parameter attribute flags
+ * @KAPI_PARAM_IN: Input parameter
+ * @KAPI_PARAM_OUT: Output parameter
+ * @KAPI_PARAM_INOUT: Input/output parameter
+ * @KAPI_PARAM_OPTIONAL: Optional parameter (can be NULL)
+ * @KAPI_PARAM_CONST: Const qualified parameter
+ * @KAPI_PARAM_VOLATILE: Volatile qualified parameter
+ * @KAPI_PARAM_USER: User space pointer
+ * @KAPI_PARAM_DMA: DMA-capable memory required
+ * @KAPI_PARAM_ALIGNED: Alignment requirements
+ */
+enum kapi_param_flags {
+	KAPI_PARAM_IN		= (1 << 0),
+	KAPI_PARAM_OUT		= (1 << 1),
+	KAPI_PARAM_INOUT	= (KAPI_PARAM_IN | KAPI_PARAM_OUT),
+	KAPI_PARAM_OPTIONAL	= (1 << 3),
+	KAPI_PARAM_CONST	= (1 << 4),
+	KAPI_PARAM_VOLATILE	= (1 << 5),
+	KAPI_PARAM_USER		= (1 << 6),
+	KAPI_PARAM_DMA		= (1 << 7),
+	KAPI_PARAM_ALIGNED	= (1 << 8),
+};
+
+/**
+ * enum kapi_context_flags - Function execution context flags
+ * @KAPI_CTX_PROCESS: Can be called from process context
+ * @KAPI_CTX_SOFTIRQ: Can be called from softirq context
+ * @KAPI_CTX_HARDIRQ: Can be called from hardirq context
+ * @KAPI_CTX_NMI: Can be called from NMI context
+ * @KAPI_CTX_ATOMIC: Must be called in atomic context
+ * @KAPI_CTX_SLEEPABLE: May sleep
+ * @KAPI_CTX_PREEMPT_DISABLED: Requires preemption disabled
+ * @KAPI_CTX_IRQ_DISABLED: Requires interrupts disabled
+ */
+enum kapi_context_flags {
+	KAPI_CTX_PROCESS	= (1 << 0),
+	KAPI_CTX_SOFTIRQ	= (1 << 1),
+	KAPI_CTX_HARDIRQ	= (1 << 2),
+	KAPI_CTX_NMI		= (1 << 3),
+	KAPI_CTX_ATOMIC		= (1 << 4),
+	KAPI_CTX_SLEEPABLE	= (1 << 5),
+	KAPI_CTX_PREEMPT_DISABLED = (1 << 6),
+	KAPI_CTX_IRQ_DISABLED	= (1 << 7),
+};
+
+/**
+ * enum kapi_lock_type - Lock types used/required by the function
+ * @KAPI_LOCK_NONE: No locking requirements
+ * @KAPI_LOCK_MUTEX: Mutex lock
+ * @KAPI_LOCK_SPINLOCK: Spinlock
+ * @KAPI_LOCK_RWLOCK: Read-write lock
+ * @KAPI_LOCK_SEQLOCK: Sequence lock
+ * @KAPI_LOCK_RCU: RCU lock
+ * @KAPI_LOCK_SEMAPHORE: Semaphore
+ * @KAPI_LOCK_CUSTOM: Custom locking mechanism
+ */
+enum kapi_lock_type {
+	KAPI_LOCK_NONE = 0,
+	KAPI_LOCK_MUTEX,
+	KAPI_LOCK_SPINLOCK,
+	KAPI_LOCK_RWLOCK,
+	KAPI_LOCK_SEQLOCK,
+	KAPI_LOCK_RCU,
+	KAPI_LOCK_SEMAPHORE,
+	KAPI_LOCK_CUSTOM,
+};
+
+/**
+ * enum kapi_constraint_type - Types of parameter constraints
+ * @KAPI_CONSTRAINT_NONE: No constraint
+ * @KAPI_CONSTRAINT_RANGE: Numeric range constraint
+ * @KAPI_CONSTRAINT_MASK: Bitmask constraint
+ * @KAPI_CONSTRAINT_ENUM: Enumerated values constraint
+ * @KAPI_CONSTRAINT_ALIGNMENT: Alignment constraint (must be aligned to specified boundary)
+ * @KAPI_CONSTRAINT_POWER_OF_TWO: Value must be a power of two
+ * @KAPI_CONSTRAINT_PAGE_ALIGNED: Value must be page-aligned
+ * @KAPI_CONSTRAINT_NONZERO: Value must be non-zero
+ * @KAPI_CONSTRAINT_USER_STRING: Userspace null-terminated string with length range
+ * @KAPI_CONSTRAINT_USER_PATH: Userspace pathname string (validated for accessibility and PATH_MAX)
+ * @KAPI_CONSTRAINT_USER_PTR: Userspace pointer (validated for accessibility and size)
+ * @KAPI_CONSTRAINT_BUFFER: Userspace buffer pointer (validated by copy_to/from_user)
+ * @KAPI_CONSTRAINT_CUSTOM: Custom validation function
+ */
+enum kapi_constraint_type {
+	KAPI_CONSTRAINT_NONE = 0,
+	KAPI_CONSTRAINT_RANGE,
+	KAPI_CONSTRAINT_MASK,
+	KAPI_CONSTRAINT_ENUM,
+	KAPI_CONSTRAINT_ALIGNMENT,
+	KAPI_CONSTRAINT_POWER_OF_TWO,
+	KAPI_CONSTRAINT_PAGE_ALIGNED,
+	KAPI_CONSTRAINT_NONZERO,
+	KAPI_CONSTRAINT_USER_STRING,
+	KAPI_CONSTRAINT_USER_PATH,
+	KAPI_CONSTRAINT_USER_PTR,
+	KAPI_CONSTRAINT_BUFFER,
+	KAPI_CONSTRAINT_CUSTOM,
+};
+
+/**
+ * struct kapi_param_spec - Parameter specification
+ * @name: Parameter name
+ * @type_name: Type name as string
+ * @type: Parameter type classification
+ * @flags: Parameter attribute flags
+ * @size: Size in bytes (for arrays/buffers)
+ * @alignment: Required alignment
+ * @min_value: Minimum valid value (for numeric types)
+ * @max_value: Maximum valid value (for numeric types)
+ * @valid_mask: Valid bits mask (for flag parameters)
+ * @enum_values: Array of valid enumerated values
+ * @enum_count: Number of valid enumerated values
+ * @constraint_type: Type of constraint applied
+ * @validate: Custom validation function
+ * @description: Human-readable description
+ * @constraints: Additional constraints description
+ * @size_param_idx: 1-based index of the parameter that determines size,
+ *                  or 0 if this parameter has a fixed size
+ * @size_multiplier: Multiplier for size calculation (e.g., sizeof(struct))
+ */
+struct kapi_param_spec {
+	const char *name;
+	const char *type_name;
+	enum kapi_param_type type;
+	u32 flags;
+	size_t size;
+	size_t alignment;
+	s64 min_value;
+	s64 max_value;
+	u64 valid_mask;
+	const s64 *enum_values;
+	u32 enum_count;
+	enum kapi_constraint_type constraint_type;
+	bool (*validate)(s64 value);
+	const char *description;
+	const char *constraints;
+	int size_param_idx;	/* 1-based param index for dynamic size; 0 if N/A */
+	size_t size_multiplier;	/* Size per unit (e.g., sizeof(struct epoll_event)) */
+};
+
+/**
+ * struct kapi_error_spec - Error condition specification
+ * @error_code: Error code value
+ * @name: Error code name (e.g., "EINVAL")
+ * @condition: Condition that triggers this error
+ * @description: Detailed error description
+ */
+struct kapi_error_spec {
+	int error_code;
+	const char *name;
+	const char *condition;
+	const char *description;
+};
+
+/**
+ * enum kapi_return_check_type - Return value check types
+ * @KAPI_RETURN_EXACT: Success is an exact value
+ * @KAPI_RETURN_RANGE: Success is within a range
+ * @KAPI_RETURN_ERROR_CHECK: Success is when NOT in error list
+ * @KAPI_RETURN_FD: Return value is a file descriptor (>= 0 is success)
+ * @KAPI_RETURN_CUSTOM: Custom validation function
+ * @KAPI_RETURN_NO_RETURN: Function does not return (e.g., exec on success)
+ */
+enum kapi_return_check_type {
+	KAPI_RETURN_EXACT,
+	KAPI_RETURN_RANGE,
+	KAPI_RETURN_ERROR_CHECK,
+	KAPI_RETURN_FD,
+	KAPI_RETURN_CUSTOM,
+	KAPI_RETURN_NO_RETURN,
+};
+
+/**
+ * struct kapi_return_spec - Return value specification
+ * @type_name: Return type name
+ * @type: Return type classification
+ * @check_type: Type of success check to perform
+ * @success_value: Exact value indicating success (for EXACT)
+ * @success_min: Minimum success value (for RANGE)
+ * @success_max: Maximum success value (for RANGE)
+ * @error_values: Array of error values (for ERROR_CHECK)
+ * @error_count: Number of error values
+ * @is_success: Custom function to check success
+ * @description: Return value description
+ */
+struct kapi_return_spec {
+	const char *type_name;
+	enum kapi_param_type type;
+	enum kapi_return_check_type check_type;
+	s64 success_value;
+	s64 success_min;
+	s64 success_max;
+	const s64 *error_values;
+	u32 error_count;
+	bool (*is_success)(s64 retval);
+	const char *description;
+};
+
+/**
+ * enum kapi_lock_scope - Lock acquisition/release scope
+ * @KAPI_LOCK_INTERNAL: Lock is acquired and released within the function (common case)
+ * @KAPI_LOCK_ACQUIRES: Function acquires lock but does not release it
+ * @KAPI_LOCK_RELEASES: Function releases lock (must be held on entry)
+ * @KAPI_LOCK_CALLER_HELD: Lock must be held by caller throughout the call
+ */
+enum kapi_lock_scope {
+	KAPI_LOCK_INTERNAL = 0,
+	KAPI_LOCK_ACQUIRES,
+	KAPI_LOCK_RELEASES,
+	KAPI_LOCK_CALLER_HELD,
+};
+
+/**
+ * struct kapi_lock_spec - Lock requirement specification
+ * @lock_name: Name of the lock
+ * @lock_type: Type of lock
+ * @scope: Lock scope (internal, acquires, releases, or caller-held)
+ * @description: Additional lock requirements
+ */
+struct kapi_lock_spec {
+	const char *lock_name;
+	enum kapi_lock_type lock_type;
+	enum kapi_lock_scope scope;
+	const char *description;
+};
+
+/**
+ * struct kapi_constraint_spec - Additional constraint specification
+ * @name: Constraint name
+ * @description: Constraint description
+ * @expression: Formal expression (if applicable)
+ */
+struct kapi_constraint_spec {
+	const char *name;
+	const char *description;
+	const char *expression;
+};
+
+/**
+ * enum kapi_signal_direction - Signal flow direction
+ * @KAPI_SIGNAL_RECEIVE: Function may receive this signal
+ * @KAPI_SIGNAL_SEND: Function may send this signal
+ * @KAPI_SIGNAL_HANDLE: Function handles this signal specially
+ * @KAPI_SIGNAL_BLOCK: Function blocks this signal
+ * @KAPI_SIGNAL_IGNORE: Function ignores this signal
+ */
+enum kapi_signal_direction {
+	KAPI_SIGNAL_RECEIVE	= (1 << 0),
+	KAPI_SIGNAL_SEND	= (1 << 1),
+	KAPI_SIGNAL_HANDLE	= (1 << 2),
+	KAPI_SIGNAL_BLOCK	= (1 << 3),
+	KAPI_SIGNAL_IGNORE	= (1 << 4),
+};
+
+/**
+ * enum kapi_signal_action - What the function does with the signal
+ * @KAPI_SIGNAL_ACTION_DEFAULT: Default signal action applies
+ * @KAPI_SIGNAL_ACTION_TERMINATE: Causes termination
+ * @KAPI_SIGNAL_ACTION_COREDUMP: Causes termination with core dump
+ * @KAPI_SIGNAL_ACTION_STOP: Stops the process
+ * @KAPI_SIGNAL_ACTION_CONTINUE: Continues a stopped process
+ * @KAPI_SIGNAL_ACTION_CUSTOM: Custom handling described in notes
+ * @KAPI_SIGNAL_ACTION_RETURN: Returns from syscall with EINTR
+ * @KAPI_SIGNAL_ACTION_RESTART: Restarts the syscall
+ * @KAPI_SIGNAL_ACTION_QUEUE: Queues the signal for later delivery
+ * @KAPI_SIGNAL_ACTION_DISCARD: Discards the signal
+ * @KAPI_SIGNAL_ACTION_TRANSFORM: Transforms to another signal
+ */
+enum kapi_signal_action {
+	KAPI_SIGNAL_ACTION_DEFAULT = 0,
+	KAPI_SIGNAL_ACTION_TERMINATE,
+	KAPI_SIGNAL_ACTION_COREDUMP,
+	KAPI_SIGNAL_ACTION_STOP,
+	KAPI_SIGNAL_ACTION_CONTINUE,
+	KAPI_SIGNAL_ACTION_CUSTOM,
+	KAPI_SIGNAL_ACTION_RETURN,
+	KAPI_SIGNAL_ACTION_RESTART,
+	KAPI_SIGNAL_ACTION_QUEUE,
+	KAPI_SIGNAL_ACTION_DISCARD,
+	KAPI_SIGNAL_ACTION_TRANSFORM,
+};
+
+/**
+ * struct kapi_signal_spec - Signal specification
+ * @signal_num: Signal number (e.g., SIGKILL, SIGTERM)
+ * @signal_name: Signal name as string
+ * @direction: Direction flags (OR of kapi_signal_direction)
+ * @action: What happens when signal is received
+ * @target: Description of target process/thread for sent signals
+ * @condition: Condition under which signal is sent/received/handled
+ * @description: Detailed description of signal handling
+ * @restartable: Whether syscall is restartable after this signal
+ * @sa_flags_required: Required signal action flags (SA_*)
+ * @sa_flags_forbidden: Forbidden signal action flags
+ * @error_on_signal: Error code returned when signal occurs (-EINTR, etc)
+ * @transform_to: Signal number to transform to (if action is TRANSFORM)
+ * @timing: When signal can occur ("entry", "during", "exit", "anytime")
+ * @priority: Signal handling priority (lower processed first)
+ * @interruptible: Whether this operation is interruptible by this signal
+ * @queue_behavior: How signal is queued ("realtime", "standard", "coalesce")
+ * @state_required: Required process state for signal to be delivered
+ * @state_forbidden: Forbidden process state for signal delivery
+ */
+struct kapi_signal_spec {
+	int signal_num;
+	const char *signal_name;
+	u32 direction;
+	enum kapi_signal_action action;
+	const char *target;
+	const char *condition;
+	const char *description;
+	bool restartable;
+	u32 sa_flags_required;
+	u32 sa_flags_forbidden;
+	int error_on_signal;
+	int transform_to;
+	const char *timing;
+	u8 priority;
+	bool interruptible;
+	const char *queue_behavior;
+	u32 state_required;
+	u32 state_forbidden;
+};
+
+/**
+ * struct kapi_signal_mask_spec - Signal mask specification
+ * @mask_name: Name of the signal mask
+ * @signals: Array of signal numbers in the mask
+ * @signal_count: Number of signals in the mask
+ * @description: Description of what this mask represents
+ */
+struct kapi_signal_mask_spec {
+	const char *mask_name;
+	int signals[KAPI_MAX_SIGNALS];
+	u32 signal_count;
+	const char *description;
+};
+
+/**
+ * struct kapi_struct_field - Structure field specification
+ * @name: Field name
+ * @type: Field type classification
+ * @type_name: Type name as string
+ * @offset: Offset within structure
+ * @size: Size of field in bytes
+ * @flags: Field attribute flags
+ * @constraint_type: Type of constraint applied
+ * @min_value: Minimum valid value (for numeric types)
+ * @max_value: Maximum valid value (for numeric types)
+ * @valid_mask: Valid bits mask (for flag fields)
+ * @enum_values: Comma-separated list of valid enum values (for enum types)
+ * @description: Field description
+ */
+struct kapi_struct_field {
+	const char *name;
+	enum kapi_param_type type;
+	const char *type_name;
+	size_t offset;
+	size_t size;
+	u32 flags;
+	enum kapi_constraint_type constraint_type;
+	s64 min_value;
+	s64 max_value;
+	u64 valid_mask;
+	const char *enum_values;	/* Comma-separated list of valid enum values */
+	const char *description;
+};
+
+/**
+ * struct kapi_struct_spec - Structure type specification
+ * @name: Structure name
+ * @size: Total size of structure
+ * @alignment: Required alignment
+ * @field_count: Number of fields
+ * @fields: Field specifications
+ * @description: Structure description
+ */
+struct kapi_struct_spec {
+	const char *name;
+	size_t size;
+	size_t alignment;
+	u32 field_count;
+	struct kapi_struct_field fields[KAPI_MAX_PARAMS];
+	const char *description;
+};
+
+/**
+ * enum kapi_capability_action - What the capability allows
+ * @KAPI_CAP_BYPASS_CHECK: Bypasses a check entirely
+ * @KAPI_CAP_INCREASE_LIMIT: Increases or removes a limit
+ * @KAPI_CAP_OVERRIDE_RESTRICTION: Overrides a restriction
+ * @KAPI_CAP_GRANT_PERMISSION: Grants permission that would otherwise be denied
+ * @KAPI_CAP_MODIFY_BEHAVIOR: Changes the behavior of the operation
+ * @KAPI_CAP_ACCESS_RESOURCE: Allows access to restricted resources
+ * @KAPI_CAP_PERFORM_OPERATION: Allows performing a privileged operation
+ */
+enum kapi_capability_action {
+	KAPI_CAP_BYPASS_CHECK = 0,
+	KAPI_CAP_INCREASE_LIMIT,
+	KAPI_CAP_OVERRIDE_RESTRICTION,
+	KAPI_CAP_GRANT_PERMISSION,
+	KAPI_CAP_MODIFY_BEHAVIOR,
+	KAPI_CAP_ACCESS_RESOURCE,
+	KAPI_CAP_PERFORM_OPERATION,
+};
+
+/**
+ * struct kapi_capability_spec - Capability requirement specification
+ * @capability: The capability constant (e.g., CAP_IPC_LOCK)
+ * @cap_name: Capability name as string
+ * @action: What the capability allows (kapi_capability_action)
+ * @allows: Description of what the capability allows
+ * @without_cap: What happens without the capability
+ * @check_condition: Condition when capability is checked
+ * @priority: Check priority (lower checked first)
+ * @alternative: Alternative capabilities that can be used
+ * @alternative_count: Number of alternative capabilities
+ */
+struct kapi_capability_spec {
+	int capability;
+	const char *cap_name;
+	enum kapi_capability_action action;
+	const char *allows;
+	const char *without_cap;
+	const char *check_condition;
+	u8 priority;
+	int alternative[KAPI_MAX_CAPABILITIES];
+	u32 alternative_count;
+};
+
+/**
+ * enum kapi_side_effect_type - Types of side effects
+ * @KAPI_EFFECT_NONE: No side effects
+ * @KAPI_EFFECT_ALLOC_MEMORY: Allocates memory
+ * @KAPI_EFFECT_FREE_MEMORY: Frees memory
+ * @KAPI_EFFECT_MODIFY_STATE: Modifies global/shared state
+ * @KAPI_EFFECT_SIGNAL_SEND: Sends signals
+ * @KAPI_EFFECT_FILE_POSITION: Modifies file position
+ * @KAPI_EFFECT_LOCK_ACQUIRE: Acquires locks
+ * @KAPI_EFFECT_LOCK_RELEASE: Releases locks
+ * @KAPI_EFFECT_RESOURCE_CREATE: Creates system resources (FDs, PIDs, etc)
+ * @KAPI_EFFECT_RESOURCE_DESTROY: Destroys system resources
+ * @KAPI_EFFECT_SCHEDULE: May cause scheduling/context switch
+ * @KAPI_EFFECT_HARDWARE: Interacts with hardware
+ * @KAPI_EFFECT_NETWORK: Network I/O operation
+ * @KAPI_EFFECT_FILESYSTEM: Filesystem modification
+ * @KAPI_EFFECT_PROCESS_STATE: Modifies process state
+ * @KAPI_EFFECT_IRREVERSIBLE: Effect cannot be undone
+ */
+enum kapi_side_effect_type {
+	KAPI_EFFECT_NONE = 0,
+	KAPI_EFFECT_ALLOC_MEMORY = (1 << 0),
+	KAPI_EFFECT_FREE_MEMORY = (1 << 1),
+	KAPI_EFFECT_MODIFY_STATE = (1 << 2),
+	KAPI_EFFECT_SIGNAL_SEND = (1 << 3),
+	KAPI_EFFECT_FILE_POSITION = (1 << 4),
+	KAPI_EFFECT_LOCK_ACQUIRE = (1 << 5),
+	KAPI_EFFECT_LOCK_RELEASE = (1 << 6),
+	KAPI_EFFECT_RESOURCE_CREATE = (1 << 7),
+	KAPI_EFFECT_RESOURCE_DESTROY = (1 << 8),
+	KAPI_EFFECT_SCHEDULE = (1 << 9),
+	KAPI_EFFECT_HARDWARE = (1 << 10),
+	KAPI_EFFECT_NETWORK = (1 << 11),
+	KAPI_EFFECT_FILESYSTEM = (1 << 12),
+	KAPI_EFFECT_PROCESS_STATE = (1 << 13),
+	KAPI_EFFECT_IRREVERSIBLE = (1 << 14),
+};
+
+/**
+ * struct kapi_side_effect - Side effect specification
+ * @type: Bitmask of effect types
+ * @target: What is affected (e.g., "process memory", "file descriptor table")
+ * @condition: Condition under which effect occurs
+ * @description: Detailed description of the effect
+ * @reversible: Whether the effect can be undone
+ */
+struct kapi_side_effect {
+	u32 type;
+	const char *target;
+	const char *condition;
+	const char *description;
+	bool reversible;
+};
+
+/**
+ * struct kapi_state_transition - State transition specification
+ * @from_state: Starting state description
+ * @to_state: Ending state description
+ * @condition: Condition for transition
+ * @object: Object whose state changes
+ * @description: Detailed description
+ */
+struct kapi_state_transition {
+	const char *from_state;
+	const char *to_state;
+	const char *condition;
+	const char *object;
+	const char *description;
+};
+
+#define KAPI_MAX_STRUCT_SPECS	8
+#define KAPI_MAX_SIDE_EFFECTS	32
+#define KAPI_MAX_STATE_TRANS	8
+
+/**
+ * struct kernel_api_spec - Complete kernel API specification
+ * @name: Function name
+ * @version: API version
+ * @description: Brief description
+ * @long_description: Detailed description
+ * @context_flags: Execution context flags
+ * @param_count: Number of parameters
+ * @params: Parameter specifications
+ * @return_spec: Return value specification
+ * @error_count: Number of possible errors
+ * @errors: Error specifications
+ * @lock_count: Number of lock specifications
+ * @locks: Lock requirement specifications
+ * @constraint_count: Number of additional constraints
+ * @constraints: Additional constraint specifications
+ * @examples: Usage examples
+ * @notes: Additional notes
+ * @signal_count: Number of signal specifications
+ * @signals: Signal handling specifications
+ * @signal_mask_count: Number of signal mask specifications
+ * @signal_masks: Signal mask specifications
+ * @struct_spec_count: Number of structure specifications
+ * @struct_specs: Structure type specifications
+ * @side_effect_count: Number of side effect specifications
+ * @side_effects: Side effect specifications
+ * @state_trans_count: Number of state transition specifications
+ * @state_transitions: State transition specifications
+ * @capability_count: Number of required capabilities
+ * @capabilities: Required capability specifications
+ * @param_magic: Magic value marking the start of the params array
+ * @return_magic: Magic value marking the return spec
+ * @error_magic: Magic value marking the start of the errors array
+ * @lock_magic: Magic value marking the start of the locks array
+ * @constraint_magic: Magic value marking the constraints array
+ * @info_magic: Magic value marking the info block (examples, notes)
+ * @signal_magic: Magic value marking the start of the signals array
+ * @sigmask_magic: Magic value marking the signal masks array
+ * @struct_magic: Magic value marking the struct specs array
+ * @effect_magic: Magic value marking the side effects array
+ * @trans_magic: Magic value marking the state transitions array
+ * @cap_magic: Magic value marking the capabilities array
+ */
+struct kernel_api_spec {
+	const char *name;
+	u32 version;
+	const char *description;
+	const char *long_description;
+	u32 context_flags;
+
+	/* Parameters */
+	u32 param_magic;  /* 0x4B415031 = 'KAP1' */
+	u32 param_count;
+	struct kapi_param_spec params[KAPI_MAX_PARAMS];
+
+	/* Return value */
+	u32 return_magic; /* 0x4B415232 = 'KAR2' */
+	struct kapi_return_spec return_spec;
+
+	/* Errors */
+	u32 error_magic;  /* 0x4B414533 = 'KAE3' */
+	u32 error_count;
+	struct kapi_error_spec errors[KAPI_MAX_ERRORS];
+
+	/* Locking */
+	u32 lock_magic;   /* 0x4B414C34 = 'KAL4' */
+	u32 lock_count;
+	struct kapi_lock_spec locks[KAPI_MAX_LOCKS];
+
+	/* Constraints */
+	u32 constraint_magic; /* 0x4B414335 = 'KAC5' */
+	u32 constraint_count;
+	struct kapi_constraint_spec constraints[KAPI_MAX_CONSTRAINTS];
+
+	/* Additional information */
+	u32 info_magic;   /* 0x4B414936 = 'KAI6' */
+	const char *examples;
+	const char *notes;
+
+	/* Signal specifications */
+	u32 signal_magic; /* 0x4B415337 = 'KAS7' */
+	u32 signal_count;
+	struct kapi_signal_spec signals[KAPI_MAX_SIGNALS];
+
+	/* Signal mask specifications */
+	u32 sigmask_magic; /* 0x4B414D38 = 'KAM8' */
+	u32 signal_mask_count;
+	struct kapi_signal_mask_spec signal_masks[KAPI_MAX_SIGNALS];
+
+	/* Structure specifications */
+	u32 struct_magic; /* 0x4B415439 = 'KAT9' */
+	u32 struct_spec_count;
+	struct kapi_struct_spec struct_specs[KAPI_MAX_STRUCT_SPECS];
+
+	/* Side effects */
+	u32 effect_magic; /* 0x4B414641 = 'KAFA' */
+	u32 side_effect_count;
+	struct kapi_side_effect side_effects[KAPI_MAX_SIDE_EFFECTS];
+
+	/* State transitions */
+	u32 trans_magic;  /* 0x4B415442 = 'KATB' */
+	u32 state_trans_count;
+	struct kapi_state_transition state_transitions[KAPI_MAX_STATE_TRANS];
+
+	/* Capability specifications */
+	u32 cap_magic;    /* 0x4B414343 = 'KACC' */
+	u32 capability_count;
+	struct kapi_capability_spec capabilities[KAPI_MAX_CAPABILITIES];
+};
+
+/* Macros for defining API specifications */
+
+/**
+ * DEFINE_KERNEL_API_SPEC - Define a kernel API specification
+ * @func_name: Function name to specify
+ *
+ * The ``.kapi_specs`` section holds an array of pointers to
+ * fully-defined ``kernel_api_spec`` instances, tightly packed so
+ * iteration ``for (pp = __start_kapi_specs; pp < __stop_kapi_specs;
+ * pp++)`` advances by one pointer each step regardless of the real
+ * spec struct size.
+ */
+#define DEFINE_KERNEL_API_SPEC(func_name)					\
+	extern const struct kernel_api_spec __kapi_spec_##func_name;		\
+	static const struct kernel_api_spec * const				\
+	__kapi_spec_ptr_##func_name __used __section(".kapi_specs") =		\
+		&__kapi_spec_##func_name;					\
+	const struct kernel_api_spec __kapi_spec_##func_name = {		\
+		.name = __stringify(func_name),					\
+		.version = 1,
+
+/**
+ * KAPI_DESCRIPTION - Set API description
+ * @desc: Description string
+ */
+#define KAPI_DESCRIPTION(desc) \
+	.description = desc,
+
+/**
+ * KAPI_LONG_DESC - Set detailed API description
+ * @desc: Detailed description string
+ */
+#define KAPI_LONG_DESC(desc) \
+	.long_description = desc,
+
+/**
+ * KAPI_CONTEXT - Set execution context flags
+ * @flags: Context flags (OR'ed KAPI_CTX_* values)
+ */
+#define KAPI_CONTEXT(flags) \
+	.context_flags = flags,
+
+/**
+ * KAPI_PARAM - Define a parameter specification
+ * @idx: Parameter index (0-based)
+ * @pname: Parameter name
+ * @ptype: Type name string
+ * @pdesc: Parameter description
+ */
+#define KAPI_PARAM(idx, pname, ptype, pdesc) \
+	.params[idx] = {			\
+		.name = pname,			\
+		.type_name = ptype,		\
+		.description = pdesc,
+
+#define KAPI_PARAM_TYPE(ptype) \
+		.type = ptype,
+
+#define KAPI_PARAM_FLAGS(pflags) \
+		.flags = pflags,
+
+#define KAPI_PARAM_SIZE(psize) \
+		.size = psize,
+
+#define KAPI_PARAM_RANGE(pmin, pmax) \
+		.min_value = pmin,	\
+		.max_value = pmax,
+
+#define KAPI_PARAM_CONSTRAINT_TYPE(ctype) \
+		.constraint_type = ctype,
+
+#define KAPI_PARAM_CONSTRAINT(desc) \
+		.constraints = desc,
+
+#define KAPI_PARAM_VALID_MASK(mask) \
+		.valid_mask = mask,
+
+/**
+ * KAPI_PARAM_ENUM_VALUES - Set the valid values of an enumerated parameter
+ * @...: Variadic list of valid values
+ */
+#define KAPI_PARAM_ENUM_VALUES(...) \
+		.enum_values = (const s64[]){ __VA_ARGS__ }, \
+		.enum_count = sizeof((const s64[]){ __VA_ARGS__ }) / sizeof(s64),
+
+#define KAPI_PARAM_ALIGNMENT(align) \
+		.alignment = align,
+
+/*
+ * Store the 1-based parameter index so the zero-initialised default
+ * (no dynamic sizing) remains distinguishable from "uses param 0".
+ */
+#define KAPI_PARAM_SIZE_PARAM(idx) \
+		.size_param_idx = (idx) + 1,
+
+/**
+ * KAPI_PARAM_COUNT - Set the number of parameters
+ * @n: Number of parameters
+ */
+#define KAPI_PARAM_COUNT(n) \
+	.param_magic = KAPI_MAGIC_PARAMS, \
+	.param_count = n,
+
+/**
+ * KAPI_RETURN - Define return value specification
+ * @rtype: Return type name
+ * @rdesc: Return value description
+ */
+#define KAPI_RETURN(rtype, rdesc) \
+	.return_magic = KAPI_MAGIC_RETURN, \
+	.return_spec = {		\
+		.type_name = rtype,	\
+		.description = rdesc,
+
+#define KAPI_RETURN_SUCCESS(val, ...) \
+		.success_value = val,
+
+#define KAPI_RETURN_TYPE(rtype) \
+		.type = rtype,
+
+#define KAPI_RETURN_CHECK_TYPE(ctype) \
+		.check_type = ctype,
+
+#define KAPI_RETURN_ERROR_VALUES(values) \
+		.error_values = values,
+
+#define KAPI_RETURN_ERROR_COUNT(count) \
+		.error_count = count,
+
+#define KAPI_RETURN_SUCCESS_RANGE(min, max) \
+		.success_min = min, \
+		.success_max = max,
+
+/**
+ * KAPI_ERROR - Define an error condition
+ * @idx: Error index
+ * @ecode: Error code value
+ * @ename: Error name
+ * @econd: Error condition
+ * @edesc: Error description
+ */
+#define KAPI_ERROR(idx, ecode, ename, econd, edesc) \
+	.errors[idx] = {			\
+		.error_code = ecode,		\
+		.name = ename,			\
+		.condition = econd,		\
+		.description = edesc,		\
+	},
+
+/**
+ * KAPI_ERROR_COUNT - Set the number of errors
+ * @n: Number of errors
+ */
+#define KAPI_ERROR_COUNT(n) \
+	.error_magic = KAPI_MAGIC_ERRORS, \
+	.error_count = n,
+
+/**
+ * KAPI_LOCK - Define a lock requirement
+ * @idx: Lock index
+ * @lname: Lock name
+ * @ltype: Lock type
+ */
+#define KAPI_LOCK(idx, lname, ltype) \
+	.locks[idx] = {			\
+		.lock_name = lname,	\
+		.lock_type = ltype,
+
+#define KAPI_LOCK_ACQUIRED \
+		.scope = KAPI_LOCK_ACQUIRES,
+
+#define KAPI_LOCK_RELEASED \
+		.scope = KAPI_LOCK_RELEASES,
+
+#define KAPI_LOCK_HELD_ENTRY \
+		.scope = KAPI_LOCK_CALLER_HELD,
+
+#define KAPI_LOCK_HELD_EXIT \
+		.scope = KAPI_LOCK_CALLER_HELD,
+
+#define KAPI_LOCK_DESC(ldesc) \
+		.description = ldesc,
+
+/**
+ * KAPI_CONSTRAINT - Define an additional constraint
+ * @idx: Constraint index
+ * @cname: Constraint name
+ * @cdesc: Constraint description
+ */
+#define KAPI_CONSTRAINT(idx, cname, cdesc) \
+	.constraints[idx] = {		\
+		.name = cname,		\
+		.description = cdesc,
+
+#define KAPI_CONSTRAINT_EXPR(expr) \
+		.expression = expr,
+
+/**
+ * KAPI_EXAMPLES - Set API usage examples
+ * @ex: Examples string
+ */
+#define KAPI_EXAMPLES(ex) \
+	.info_magic = KAPI_MAGIC_INFO, \
+	.examples = ex,
+
+/**
+ * KAPI_NOTES - Set API notes
+ * @n: Notes string
+ */
+#define KAPI_NOTES(n) \
+	.notes = n,
+
+
+/**
+ * KAPI_SIGNAL - Define a signal specification
+ * @idx: Signal index
+ * @signum: Signal number (e.g., SIGKILL)
+ * @signame: Signal name string
+ * @dir: Direction flags
+ * @act: Action taken
+ */
+#define KAPI_SIGNAL(idx, signum, signame, dir, act) \
+	.signals[idx] = {			\
+		.signal_num = signum,		\
+		.signal_name = signame,		\
+		.direction = dir,		\
+		.action = act,
+
+#define KAPI_SIGNAL_TARGET(tgt) \
+		.target = tgt,
+
+#define KAPI_SIGNAL_CONDITION(cond) \
+		.condition = cond,
+
+#define KAPI_SIGNAL_DESC(desc) \
+		.description = desc,
+
+#define KAPI_SIGNAL_RESTARTABLE \
+		.restartable = true,
+
+#define KAPI_SIGNAL_SA_FLAGS_REQ(flags) \
+		.sa_flags_required = flags,
+
+#define KAPI_SIGNAL_SA_FLAGS_FORBID(flags) \
+		.sa_flags_forbidden = flags,
+
+#define KAPI_SIGNAL_ERROR(err) \
+		.error_on_signal = err,
+
+#define KAPI_SIGNAL_TRANSFORM(sig) \
+		.transform_to = sig,
+
+#define KAPI_SIGNAL_TIMING(when) \
+		.timing = when,
+
+#define KAPI_SIGNAL_PRIORITY(prio) \
+		.priority = prio,
+
+#define KAPI_SIGNAL_INTERRUPTIBLE \
+		.interruptible = true,
+
+#define KAPI_SIGNAL_QUEUE(behavior) \
+		.queue_behavior = behavior,
+
+#define KAPI_SIGNAL_STATE_REQ(state) \
+		.state_required = state,
+
+#define KAPI_SIGNAL_STATE_FORBID(state) \
+		.state_forbidden = state,
+
+#define KAPI_SIGNAL_COUNT(n) \
+	.signal_magic = KAPI_MAGIC_SIGNALS, \
+	.signal_count = n,
+
+/**
+ * KAPI_SIGNAL_MASK - Define a signal mask specification
+ * @idx: Mask index
+ * @name: Mask name
+ * @desc: Mask description
+ */
+#define KAPI_SIGNAL_MASK(idx, name, desc) \
+	.signal_masks[idx] = {		\
+		.mask_name = name,	\
+		.description = desc,
+
+/*
+ * KAPI_SIGNAL_MASK_SIGNALS - Specify signals in a signal mask
+ * @...: Variadic list of signal numbers
+ *
+ * Usage:
+ *   KAPI_SIGNAL_MASK(0, "blocked", "Signals blocked during operation")
+ *   KAPI_SIGNAL_MASK_SIGNALS(SIGINT, SIGTERM, SIGQUIT)
+ *   },
+ */
+#define KAPI_SIGNAL_MASK_SIGNALS(...) \
+		.signals = { __VA_ARGS__ }, \
+		.signal_count = sizeof((int[]){ __VA_ARGS__ }) / sizeof(int),
+
+/**
+ * KAPI_SIGNAL_MASK_COUNT - Set the number of signal mask specifications
+ * @n: Number of signal masks
+ */
+#define KAPI_SIGNAL_MASK_COUNT(n) \
+	.sigmask_magic = KAPI_MAGIC_SIGMASK, \
+	.signal_mask_count = n,
+
+/**
+ * KAPI_STRUCT_SPEC - Define a structure specification
+ * @idx: Structure spec index
+ * @sname: Structure name
+ * @sdesc: Structure description
+ */
+#define KAPI_STRUCT_SPEC(idx, sname, sdesc) \
+	.struct_specs[idx] = {		\
+		.name = #sname,		\
+		.description = sdesc,
+
+#define KAPI_STRUCT_SIZE(ssize, salign) \
+		.size = ssize,		\
+		.alignment = salign,
+
+#define KAPI_STRUCT_FIELD_COUNT(n) \
+		.field_count = n,
+
+/**
+ * KAPI_STRUCT_FIELD - Define a structure field
+ * @fidx: Field index
+ * @fname: Field name
+ * @ftype: Field type (KAPI_TYPE_*)
+ * @ftype_name: Type name as string
+ * @fdesc: Field description
+ */
+#define KAPI_STRUCT_FIELD(fidx, fname, ftype, ftype_name, fdesc) \
+		.fields[fidx] = {	\
+			.name = fname,	\
+			.type = ftype,	\
+			.type_name = ftype_name, \
+			.description = fdesc,
+
+#define KAPI_FIELD_OFFSET(foffset) \
+			.offset = foffset,
+
+#define KAPI_FIELD_SIZE(fsize) \
+			.size = fsize,
+
+#define KAPI_FIELD_FLAGS(fflags) \
+			.flags = fflags,
+
+#define KAPI_FIELD_CONSTRAINT_RANGE(min, max) \
+			.constraint_type = KAPI_CONSTRAINT_RANGE, \
+			.min_value = min, \
+			.max_value = max,
+
+#define KAPI_FIELD_CONSTRAINT_MASK(mask) \
+			.constraint_type = KAPI_CONSTRAINT_MASK, \
+			.valid_mask = mask,
+
+#define KAPI_FIELD_CONSTRAINT_ENUM(values) \
+			.constraint_type = KAPI_CONSTRAINT_ENUM, \
+			.enum_values = values,
+
+/* Counter for structure specifications */
+#define KAPI_STRUCT_SPEC_COUNT(n) \
+	.struct_magic = KAPI_MAGIC_STRUCTS, \
+	.struct_spec_count = n,
+
+/* Additional lock-related macros */
+#define KAPI_LOCK_COUNT(n) \
+	.lock_magic = KAPI_MAGIC_LOCKS, \
+	.lock_count = n,
+
+/**
+ * KAPI_SIDE_EFFECT - Define a side effect
+ * @idx: Side effect index
+ * @etype: Effect type bitmask (OR'ed KAPI_EFFECT_* values)
+ * @etarget: What is affected
+ * @edesc: Effect description
+ */
+#define KAPI_SIDE_EFFECT(idx, etype, etarget, edesc) \
+	.side_effects[idx] = {		\
+		.type = etype,		\
+		.target = etarget,	\
+		.description = edesc,
+
+#define KAPI_EFFECT_CONDITION(cond) \
+		.condition = cond,
+
+#define KAPI_EFFECT_REVERSIBLE \
+		.reversible = true,
+
+/**
+ * KAPI_STATE_TRANS - Define a state transition
+ * @idx: State transition index
+ * @obj: Object whose state changes
+ * @from: From state
+ * @to: To state
+ * @desc: Transition description
+ */
+#define KAPI_STATE_TRANS(idx, obj, from, to, desc) \
+	.state_transitions[idx] = {	\
+		.object = obj,		\
+		.from_state = from,	\
+		.to_state = to,		\
+		.description = desc,
+
+#define KAPI_STATE_TRANS_COND(cond) \
+		.condition = cond,
+
+/* Counters for side effects and state transitions */
+#define KAPI_SIDE_EFFECT_COUNT(n) \
+	.effect_magic = KAPI_MAGIC_EFFECTS, \
+	.side_effect_count = n,
+
+#define KAPI_STATE_TRANS_COUNT(n) \
+	.trans_magic = KAPI_MAGIC_TRANS, \
+	.state_trans_count = n,
+
+/* Helper macros for common side effect patterns */
+#define KAPI_EFFECTS_MEMORY	(KAPI_EFFECT_ALLOC_MEMORY | KAPI_EFFECT_FREE_MEMORY)
+#define KAPI_EFFECTS_LOCKING	(KAPI_EFFECT_LOCK_ACQUIRE | KAPI_EFFECT_LOCK_RELEASE)
+#define KAPI_EFFECTS_RESOURCES	(KAPI_EFFECT_RESOURCE_CREATE | KAPI_EFFECT_RESOURCE_DESTROY)
+#define KAPI_EFFECTS_IO		(KAPI_EFFECT_NETWORK | KAPI_EFFECT_FILESYSTEM)
+
+/* Common signal timing constants */
+#define KAPI_SIGNAL_TIME_ENTRY		"entry"
+#define KAPI_SIGNAL_TIME_DURING		"during"
+#define KAPI_SIGNAL_TIME_EXIT		"exit"
+#define KAPI_SIGNAL_TIME_ANYTIME	"anytime"
+#define KAPI_SIGNAL_TIME_BLOCKING	"while_blocked"
+#define KAPI_SIGNAL_TIME_SLEEPING	"while_sleeping"
+#define KAPI_SIGNAL_TIME_BEFORE		"before"
+#define KAPI_SIGNAL_TIME_AFTER		"after"
+
+/* Common signal queue behaviors */
+#define KAPI_SIGNAL_QUEUE_STANDARD	"standard"
+#define KAPI_SIGNAL_QUEUE_REALTIME	"realtime"
+#define KAPI_SIGNAL_QUEUE_COALESCE	"coalesce"
+#define KAPI_SIGNAL_QUEUE_REPLACE	"replace"
+#define KAPI_SIGNAL_QUEUE_DISCARD	"discard"
+
+/* Process state flags for signal delivery */
+#define KAPI_SIGNAL_STATE_RUNNING	BIT(0)
+#define KAPI_SIGNAL_STATE_SLEEPING	BIT(1)
+#define KAPI_SIGNAL_STATE_STOPPED	BIT(2)
+#define KAPI_SIGNAL_STATE_TRACED	BIT(3)
+#define KAPI_SIGNAL_STATE_ZOMBIE	BIT(4)
+#define KAPI_SIGNAL_STATE_DEAD		BIT(5)
+
+/* Capability specification macros */
+
+/**
+ * KAPI_CAPABILITY - Define a capability requirement
+ * @idx: Capability index
+ * @cap: Capability constant (e.g., CAP_IPC_LOCK)
+ * @name: Capability name string
+ * @act: Action type (kapi_capability_action)
+ */
+#define KAPI_CAPABILITY(idx, cap, name, act) \
+	.capabilities[idx] = {		\
+		.capability = cap,	\
+		.cap_name = name,	\
+		.action = act,
+
+#define KAPI_CAP_ALLOWS(desc) \
+		.allows = desc,
+
+#define KAPI_CAP_WITHOUT(desc) \
+		.without_cap = desc,
+
+#define KAPI_CAP_CONDITION(cond) \
+		.check_condition = cond,
+
+#define KAPI_CAP_PRIORITY(prio) \
+		.priority = prio,
+
+/*
+ * KAPI_CAP_ALTERNATIVE - Capabilities that can be used instead
+ * @...: Variadic list of capability numbers
+ */
+#define KAPI_CAP_ALTERNATIVE(...) \
+		.alternative = { __VA_ARGS__ }, \
+		.alternative_count = sizeof((int[]){ __VA_ARGS__ }) / sizeof(int),
+
+/* Counter for capability specifications */
+#define KAPI_CAPABILITY_COUNT(n) \
+	.cap_magic = KAPI_MAGIC_CAPS, \
+	.capability_count = n,
+
+/* Validation and runtime checking */
+
+#ifdef CONFIG_KAPI_RUNTIME_CHECKS
+bool kapi_validate_param(const struct kapi_param_spec *param_spec, s64 value);
+bool kapi_validate_param_with_context(const struct kapi_param_spec *param_spec,
+				       s64 value, const s64 *all_params, int param_count);
+bool kapi_check_return_success(const struct kapi_return_spec *return_spec, s64 retval);
+bool kapi_validate_return_value(const struct kernel_api_spec *spec, s64 retval);
+void kapi_check_context(const struct kernel_api_spec *spec);
+#else
+static inline bool kapi_validate_param(const struct kapi_param_spec *param_spec, s64 value)
+{
+	return true;
+}
+static inline bool
+kapi_validate_param_with_context(const struct kapi_param_spec *param_spec,
+				 s64 value, const s64 *all_params, int param_count)
+{
+	return true;
+}
+static inline bool kapi_check_return_success(const struct kapi_return_spec *return_spec, s64 retval)
+{
+	return true;
+}
+static inline bool kapi_validate_return_value(const struct kernel_api_spec *spec, s64 retval)
+{
+	return true;
+}
+static inline void kapi_check_context(const struct kernel_api_spec *spec) {}
+#endif
+
+/* Export/query functions */
+int kapi_export_json(const struct kernel_api_spec *spec, char *buf, size_t size);
+
+/* Registration for dynamic APIs */
+int kapi_register_spec(const struct kernel_api_spec *spec);
+void kapi_unregister_spec(const char *name);
+
+#define KAPI_CONSTRAINT_COUNT(n) \
+	.constraint_magic = KAPI_MAGIC_CONSTRAINTS, \
+	.constraint_count = n,
+
+#endif /* _LINUX_KERNEL_API_SPEC_H */
diff --git a/include/linux/syscalls.h b/include/linux/syscalls.h
index 8413b624ad478..b062bac9ea1ed 100644
--- a/include/linux/syscalls.h
+++ b/include/linux/syscalls.h
@@ -94,6 +94,10 @@ struct file_attr;
 #include <linux/personality.h>
 #include <trace/syscall.h>
 
+#ifdef CONFIG_KAPI_RUNTIME_CHECKS
+#include <linux/kapi_syscall.h>
+#endif
+
 #ifdef CONFIG_ARCH_HAS_SYSCALL_WRAPPER
 /*
  * It may be useful for an architecture to override the definitions of the
@@ -237,6 +241,41 @@ static inline int is_syscall_trace_event(struct trace_event_call *tp_event)
 
 #define __PROTECT(...) asmlinkage_protect(__VA_ARGS__)
 
+#ifdef CONFIG_KAPI_RUNTIME_CHECKS
+/*
+ * Convert integers as (s64)(a) does, and pointers through unsigned long so
+ * they are not sign-extended on 32-bit. (unsigned long)(t)0 is an integer
+ * constant expression only if t is an integer type.
+ */
+#define __SC_CAST_TO_S64(t, a)						\
+	((__force s64)__builtin_choose_expr(				\
+		__is_constexpr((__force unsigned long)(t)0),		\
+		(a), (__force unsigned long)(a)))
+
+#define __KAPI_DO_SYS(name)	__do_kapi_sys##name
+#define __KAPI_SYSCALL_DEFINEx(x, name, ...)				\
+	static inline long __do_kapi_sys##name(__MAP(x, __SC_DECL, __VA_ARGS__)) \
+	{								\
+		const struct kernel_api_spec *__spec = kapi_get_spec("sys" #name); \
+		long ret;						\
+									\
+		if (__spec) {						\
+			s64 __params[x] = { __MAP(x, __SC_CAST_TO_S64, __VA_ARGS__) }; \
+			int __ret = kapi_validate_syscall_params(__spec, __params, x); \
+									\
+			if (__ret)					\
+				return __ret;				\
+		}							\
+		ret = __do_sys##name(__MAP(x, __SC_ARGS, __VA_ARGS__));	\
+		if (__spec)						\
+			kapi_validate_syscall_return(__spec, (s64)ret);	\
+		return ret;						\
+	}
+#else
+#define __KAPI_DO_SYS(name)	__do_sys##name
+#define __KAPI_SYSCALL_DEFINEx(x, name, ...)
+#endif
+
 /*
  * The asmlinkage stub is aliased to a function named __se_sys_*() which
  * sign-extends 32-bit ints to longs whenever needed. The actual work is
@@ -255,10 +294,11 @@ static inline int is_syscall_trace_event(struct trace_event_call *tp_event)
 		__attribute__((alias(__stringify(__se_sys##name))));	\
 	ALLOW_ERROR_INJECTION(sys##name, ERRNO);			\
 	static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__));\
+	__KAPI_SYSCALL_DEFINEx(x, name, __VA_ARGS__)			\
 	asmlinkage long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__));	\
 	asmlinkage long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__))	\
 	{								\
-		long ret = __do_sys##name(__MAP(x,__SC_CAST,__VA_ARGS__));\
+		long ret = __KAPI_DO_SYS(name)(__MAP(x,__SC_CAST,__VA_ARGS__));\
 		__MAP(x,__SC_TEST,__VA_ARGS__);				\
 		__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__));	\
 		return ret;						\
diff --git a/init/Kconfig b/init/Kconfig
index ad592fdf29af4..66c23c4147498 100644
--- a/init/Kconfig
+++ b/init/Kconfig
@@ -2306,6 +2306,8 @@ source "kernel/Kconfig.kexec"
 
 source "kernel/liveupdate/Kconfig"
 
+source "kernel/api/Kconfig"
+
 endmenu		# General setup
 
 source "arch/Kconfig"
diff --git a/kernel/Makefile b/kernel/Makefile
index 1e1a31673577d..7fd43127d5eee 100644
--- a/kernel/Makefile
+++ b/kernel/Makefile
@@ -59,6 +59,7 @@ obj-y += dma/
 obj-y += entry/
 obj-y += unwind/
 obj-$(CONFIG_MODULES) += module/
+obj-$(CONFIG_KAPI_SPEC) += api/
 
 obj-$(CONFIG_KCMP) += kcmp.o
 obj-$(CONFIG_FREEZER) += freezer.o
diff --git a/kernel/api/Kconfig b/kernel/api/Kconfig
new file mode 100644
index 0000000000000..1cd55b252f0d5
--- /dev/null
+++ b/kernel/api/Kconfig
@@ -0,0 +1,54 @@
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# Kernel API Specification Framework Configuration
+#
+
+config KAPI_SPEC
+	bool "Kernel API Specification Framework"
+	help
+	  This option enables the kernel API specification framework,
+	  which provides formal documentation of kernel APIs in both
+	  human and machine-readable formats.
+
+	  The framework allows developers to document APIs inline with
+	  their implementation, including parameter specifications,
+	  return values, error conditions, locking requirements, and
+	  execution context constraints.
+
+	  When enabled, API specifications can be queried at runtime
+	  and exported in JSON format through debugfs.
+
+	  If unsure, say N.
+
+config KAPI_RUNTIME_CHECKS
+	bool "Runtime API specification checks"
+	depends on KAPI_SPEC
+	depends on DEBUG_KERNEL
+	# The hook is only in the generic and the x86 __SYSCALL_DEFINEx()
+	depends on X86 || !ARCH_HAS_SYSCALL_WRAPPER
+	help
+	  Validate the arguments and the return value of system calls that
+	  have an API specification, in their SYSCALL_DEFINEx() wrapper.
+	  Violations are reported with pr_warn_ratelimited(). This adds
+	  overhead to every system call.
+
+	  DEBUG-ONLY: Enabling this changes the errno seen by userspace for
+	  syscalls that violate their parameter specification. On violation
+	  the validator short-circuits the syscall and returns -EINVAL
+	  before the real handler runs, masking whatever errno the handler
+	  would otherwise have produced. Do not enable on production
+	  kernels.
+
+	  If unsure, say N.
+
+config KAPI_KUNIT_TEST
+	tristate "KUnit tests for KAPI framework" if !KUNIT_ALL_TESTS
+	depends on KAPI_SPEC
+	depends on KUNIT
+	default KUNIT_ALL_TESTS
+	help
+	  KUnit tests for the Kernel API Specification Framework.
+	  Tests registration, lookup, validation constraints, and
+	  JSON export functionality.
+
+	  If unsure, say N.
diff --git a/kernel/api/Makefile b/kernel/api/Makefile
new file mode 100644
index 0000000000000..6e14ca243980c
--- /dev/null
+++ b/kernel/api/Makefile
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: GPL-2.0
+#
+# Makefile for the Kernel API Specification Framework
+#
+
+# Core API specification framework
+obj-y				+= kernel_api_spec.o
+
+# KUnit tests
+obj-$(CONFIG_KAPI_KUNIT_TEST) += kapi_kunit.o
diff --git a/kernel/api/internal.h b/kernel/api/internal.h
new file mode 100644
index 0000000000000..b6112bcc2e8d0
--- /dev/null
+++ b/kernel/api/internal.h
@@ -0,0 +1,25 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+/*
+ * Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+ *
+ * Internal declarations shared by the KAPI core and its debugfs
+ * interface. Not part of the public kernel API.
+ */
+
+#ifndef _KERNEL_API_INTERNAL_H
+#define _KERNEL_API_INTERNAL_H
+
+#include <linux/kernel_api_spec.h>
+
+/*
+ * Section boundaries for the `.kapi_specs` array. Defined by the
+ * linker script in include/asm-generic/vmlinux.lds.h.
+ */
+extern const struct kernel_api_spec * const __start_kapi_specs[];
+extern const struct kernel_api_spec * const __stop_kapi_specs[];
+
+const char *kapi_param_type_to_string(enum kapi_param_type type);
+const char *kapi_lock_type_to_string(enum kapi_lock_type type);
+const char *kapi_lock_scope_to_string(enum kapi_lock_scope scope);
+
+#endif /* _KERNEL_API_INTERNAL_H */
diff --git a/kernel/api/kapi_kunit.c b/kernel/api/kapi_kunit.c
new file mode 100644
index 0000000000000..4f6f874c33b86
--- /dev/null
+++ b/kernel/api/kapi_kunit.c
@@ -0,0 +1,720 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+ *
+ * KUnit tests for the Kernel API Specification Framework
+ *
+ * Tests registration, lookup, validation, and JSON export functionality.
+ */
+
+#include <kunit/test.h>
+#include <linux/kernel_api_spec.h>
+#include <linux/string.h>
+#include <linux/slab.h>
+#include <linux/capability.h>
+#include <linux/mm.h>
+#include <linux/signal.h>
+#include <linux/uaccess.h>
+
+static void init_test_spec(struct kernel_api_spec *spec, const char *name)
+{
+	memset(spec, 0, sizeof(*spec));
+	spec->name = name;
+	spec->version = 1;
+	spec->description = "Test API";
+}
+
+/* kapi_register_spec with valid spec returns 0 */
+static void test_register_valid(struct kunit *test)
+{
+	struct kernel_api_spec *spec;
+	int ret;
+
+	spec = kzalloc_obj(*spec, GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+
+	init_test_spec(spec, "test_register_valid");
+
+	ret = kapi_register_spec(spec);
+	KUNIT_EXPECT_EQ(test, ret, 0);
+
+	kapi_unregister_spec("test_register_valid");
+	kfree(spec);
+}
+
+/* kapi_get_spec returns registered spec */
+static void test_lookup_registered(struct kunit *test)
+{
+	struct kernel_api_spec *spec;
+	const struct kernel_api_spec *found;
+	int ret;
+
+	spec = kzalloc_obj(*spec, GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+
+	init_test_spec(spec, "test_lookup_func");
+
+	ret = kapi_register_spec(spec);
+	KUNIT_ASSERT_EQ(test, ret, 0);
+
+	found = kapi_get_spec("test_lookup_func");
+	KUNIT_EXPECT_PTR_EQ(test, found, (const struct kernel_api_spec *)spec);
+
+	kapi_unregister_spec("test_lookup_func");
+	kfree(spec);
+}
+
+/* Double registration returns -EEXIST */
+static void test_double_register(struct kunit *test)
+{
+	struct kernel_api_spec *spec;
+	int ret;
+
+	spec = kzalloc_obj(*spec, GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+
+	init_test_spec(spec, "test_double_reg");
+
+	ret = kapi_register_spec(spec);
+	KUNIT_ASSERT_EQ(test, ret, 0);
+
+	ret = kapi_register_spec(spec);
+	KUNIT_EXPECT_EQ(test, ret, -EEXIST);
+
+	kapi_unregister_spec("test_double_reg");
+	kfree(spec);
+}
+
+/* Unregister makes spec unfindable */
+static void test_unregister(struct kunit *test)
+{
+	struct kernel_api_spec *spec;
+	const struct kernel_api_spec *found;
+	int ret;
+
+	spec = kzalloc_obj(*spec, GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+
+	init_test_spec(spec, "test_unreg_func");
+
+	ret = kapi_register_spec(spec);
+	KUNIT_ASSERT_EQ(test, ret, 0);
+
+	kapi_unregister_spec("test_unreg_func");
+
+	found = kapi_get_spec("test_unreg_func");
+	KUNIT_EXPECT_NULL(test, found);
+
+	kfree(spec);
+}
+
+/* kapi_get_spec(NULL) returns NULL */
+static void test_get_spec_null(struct kunit *test)
+{
+	const struct kernel_api_spec *found;
+
+	found = kapi_get_spec(NULL);
+	KUNIT_EXPECT_NULL(test, found);
+}
+
+/* kapi_register_spec(NULL) returns -EINVAL */
+static void test_register_null(struct kunit *test)
+{
+	int ret;
+
+	ret = kapi_register_spec(NULL);
+	KUNIT_EXPECT_EQ(test, ret, -EINVAL);
+}
+
+/* Spec with a NULL name is rejected */
+static void test_register_null_name(struct kunit *test)
+{
+	struct kernel_api_spec *spec;
+	int ret;
+
+	spec = kzalloc_obj(*spec, GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+
+	/* spec->name == NULL after zero-init; registration rejects it. */
+	ret = kapi_register_spec(spec);
+	KUNIT_EXPECT_EQ(test, ret, -EINVAL);
+
+	kfree(spec);
+}
+
+#ifdef CONFIG_KAPI_RUNTIME_CHECKS
+
+/* RANGE constraint - value in range is valid */
+static void test_constraint_range_valid(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	param.name = "test_param";
+	param.constraint_type = KAPI_CONSTRAINT_RANGE;
+	param.min_value = 0;
+	param.max_value = 100;
+
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 0));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 50));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 100));
+
+	param.min_value = -10;
+	param.max_value = -1;
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, -10));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, -1));
+
+	/* An unsigned maximum such as U64_MAX reads as -1 */
+	param.min_value = 0;
+	param.max_value = (s64)U64_MAX;
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, S64_MAX));
+}
+
+/* RANGE constraint - value out of range is invalid */
+static void test_constraint_range_invalid(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	param.name = "test_param";
+	param.constraint_type = KAPI_CONSTRAINT_RANGE;
+	param.min_value = 0;
+	param.max_value = 100;
+
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, -1));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 101));
+
+	param.min_value = -10;
+	param.max_value = -1;
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, -11));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 0));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 5));
+}
+
+/* MASK constraint - valid bits pass */
+static void test_constraint_mask_valid(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	param.name = "test_flags";
+	param.constraint_type = KAPI_CONSTRAINT_MASK;
+	param.valid_mask = 0xFF;
+
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 0x00));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 0x0F));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 0xFF));
+}
+
+/* MASK constraint - extra bits fail */
+static void test_constraint_mask_invalid(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	param.name = "test_flags";
+	param.constraint_type = KAPI_CONSTRAINT_MASK;
+	param.valid_mask = 0xFF;
+
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 0x100));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 0x1FF));
+}
+
+/* POWER_OF_TWO constraint */
+static void test_constraint_power_of_two(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	param.name = "test_pot";
+	param.constraint_type = KAPI_CONSTRAINT_POWER_OF_TWO;
+
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 1));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 2));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 4));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 8));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 0));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 3));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 5));
+}
+
+/* PAGE_ALIGNED constraint */
+static void test_constraint_page_aligned(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	param.name = "test_page";
+	param.constraint_type = KAPI_CONSTRAINT_PAGE_ALIGNED;
+
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 0));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, PAGE_SIZE));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 2 * PAGE_SIZE));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 1));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, PAGE_SIZE - 1));
+}
+
+/* NONZERO constraint */
+static void test_constraint_nonzero(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	param.name = "test_nz";
+	param.constraint_type = KAPI_CONSTRAINT_NONZERO;
+
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 0));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 1));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, -1));
+}
+
+/* Return value validation - success */
+static void test_return_validation(struct kunit *test)
+{
+	struct kernel_api_spec *spec;
+
+	spec = kunit_kzalloc(test, sizeof(*spec), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+
+	spec->name = "test_ret";
+	spec->return_magic = KAPI_MAGIC_RETURN;
+	spec->return_spec.check_type = KAPI_RETURN_EXACT;
+	spec->return_spec.success_value = 0;
+
+	KUNIT_EXPECT_TRUE(test, kapi_validate_return_value(spec, 0));
+}
+
+/* Return value validation - known error */
+static void test_return_known_error(struct kunit *test)
+{
+	struct kernel_api_spec *spec;
+
+	spec = kunit_kzalloc(test, sizeof(*spec), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+
+	spec->name = "test_ret_err";
+	spec->return_magic = KAPI_MAGIC_RETURN;
+	spec->return_spec.check_type = KAPI_RETURN_FD;
+	spec->error_count = 1;
+	spec->errors[0].error_code = -ENOENT;
+	spec->errors[0].name = "ENOENT";
+
+	/* -ENOENT is in the error list, so it's valid */
+	KUNIT_EXPECT_TRUE(test, kapi_validate_return_value(spec, -ENOENT));
+}
+
+/* Return value validation - unknown error */
+static void test_return_unknown_error(struct kunit *test)
+{
+	struct kernel_api_spec *spec;
+
+	spec = kunit_kzalloc(test, sizeof(*spec), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+
+	spec->name = "test_ret_unk";
+	spec->return_magic = KAPI_MAGIC_RETURN;
+	spec->return_spec.check_type = KAPI_RETURN_FD;
+	spec->error_count = 1;
+	spec->errors[0].error_code = -ENOENT;
+	spec->errors[0].name = "ENOENT";
+
+	/* -EPERM is not in the error list, but unlisted errors are accepted
+	 * since filesystem/device-specific errors may not be exhaustively listed
+	 */
+	KUNIT_EXPECT_TRUE(test, kapi_validate_return_value(spec, -EPERM));
+}
+
+/* ALIGNMENT constraint */
+static void test_constraint_alignment(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	param.name = "test_align";
+	param.constraint_type = KAPI_CONSTRAINT_ALIGNMENT;
+	param.alignment = 8;
+
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 0));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 8));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 16));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 1));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 7));
+}
+
+/* FD validation rejects values > INT_MAX */
+static void test_fd_int_overflow(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	param.name = "test_fd";
+	param.type = KAPI_TYPE_FD;
+	param.constraint_type = KAPI_CONSTRAINT_NONE;
+
+	/* Value that overflows int: 0x100000003 -> truncates to 3 */
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 0x100000003LL));
+}
+
+/* ENUM constraint */
+static const s64 test_enum_vals[] = { 1, 5, 10 };
+
+static void test_constraint_enum(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	param.name = "test_enum";
+	param.constraint_type = KAPI_CONSTRAINT_ENUM;
+	param.enum_values = test_enum_vals;
+	param.enum_count = ARRAY_SIZE(test_enum_vals);
+
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 1));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 5));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 10));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 0));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 3));
+	KUNIT_EXPECT_FALSE(test, kapi_validate_param(&param, 11));
+}
+
+/* BUFFER constraint always accepts (size checked at runtime) */
+static void test_constraint_buffer(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	param.name = "test_buf";
+	param.constraint_type = KAPI_CONSTRAINT_BUFFER;
+
+	/* Buffer constraint doesn't validate the value itself */
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 0));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_param(&param, 4096));
+}
+
+/* RETURN_RANGE check type */
+static void test_return_range(struct kunit *test)
+{
+	struct kernel_api_spec *spec;
+
+	spec = kunit_kzalloc(test, sizeof(*spec), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+
+	spec->name = "test_ret_range";
+	spec->return_magic = KAPI_MAGIC_RETURN;
+	spec->return_spec.check_type = KAPI_RETURN_RANGE;
+	spec->return_spec.success_min = 0;
+	spec->return_spec.success_max = 100;
+
+	KUNIT_EXPECT_TRUE(test, kapi_validate_return_value(spec, 0));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_return_value(spec, 50));
+	KUNIT_EXPECT_TRUE(test, kapi_validate_return_value(spec, 100));
+}
+
+static void init_dyn_buf_param(struct kapi_param_spec *param)
+{
+	param->name = "buf";
+	param->type = KAPI_TYPE_USER_PTR;
+	param->constraint_type = KAPI_CONSTRAINT_BUFFER;
+	param->size_param_idx = 2;
+}
+
+static bool validate_dyn_buf(const struct kapi_param_spec *param,
+			     const void __user *ptr, s64 count)
+{
+	s64 params[] = { (s64)(unsigned long)ptr, count };
+
+	return kapi_validate_param_with_context(param, params[0], params,
+						ARRAY_SIZE(params));
+}
+
+/* Dynamic buffer: any pointer is accepted when the size is 0 */
+static void test_dyn_buf_zero_count(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+	const void __user *kernel_addr = (void __user *)-(unsigned long)PAGE_SIZE;
+
+	init_dyn_buf_param(&param);
+
+	KUNIT_EXPECT_TRUE(test, validate_dyn_buf(&param, NULL, 0));
+	KUNIT_EXPECT_TRUE(test, validate_dyn_buf(&param, (void __user *)PAGE_SIZE, 0));
+	KUNIT_EXPECT_TRUE(test, validate_dyn_buf(&param, kernel_addr, 0));
+}
+
+/* Dynamic buffer: NULL is rejected when the size is non-zero */
+static void test_dyn_buf_null_nonzero_count(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+
+	init_dyn_buf_param(&param);
+
+	KUNIT_EXPECT_FALSE(test, validate_dyn_buf(&param, NULL, 1));
+	KUNIT_EXPECT_FALSE(test, validate_dyn_buf(&param, NULL, PAGE_SIZE));
+}
+
+/*
+ * Some architectures (separate user address space, !MMU) accept any address
+ * in access_ok(), so the rejection cases below only apply where it bounds
+ * user ranges.
+ */
+static bool kapi_rejects_kernel_addr(const void __user *addr)
+{
+	return !access_ok(addr, 1);
+}
+
+static bool kapi_bounds_user_range(const void __user *base)
+{
+	return access_ok(base, PAGE_SIZE) && !access_ok(base, 3 * PAGE_SIZE);
+}
+
+/* Dynamic buffer: addresses outside user space are rejected when the size is non-zero */
+static void test_dyn_buf_kernel_addr(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+	const void __user *kernel_addr = (void __user *)-(unsigned long)PAGE_SIZE;
+	const void __user *top = (void __user *)ULONG_MAX;
+
+	init_dyn_buf_param(&param);
+
+	if (!kapi_rejects_kernel_addr(kernel_addr) || !kapi_rejects_kernel_addr(top))
+		kunit_skip(test, "access_ok() accepts kernel addresses on this architecture");
+
+	KUNIT_EXPECT_FALSE(test, validate_dyn_buf(&param, kernel_addr, 1));
+	KUNIT_EXPECT_FALSE(test, validate_dyn_buf(&param, top, 1));
+}
+
+/* Dynamic buffer: an unset size_multiplier means byte units */
+static void test_dyn_buf_default_multiplier(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+	const void __user *near_top = (void __user *)(TASK_SIZE_MAX - 2 * PAGE_SIZE);
+
+	init_dyn_buf_param(&param);
+
+	KUNIT_EXPECT_TRUE(test, validate_dyn_buf(&param, (void __user *)PAGE_SIZE, 16));
+	KUNIT_EXPECT_TRUE(test, validate_dyn_buf(&param, near_top, 0));
+
+	if (!kapi_bounds_user_range(near_top))
+		kunit_skip(test, "access_ok() does not bound user ranges on this architecture");
+
+	KUNIT_EXPECT_TRUE(test, validate_dyn_buf(&param, near_top, PAGE_SIZE));
+	KUNIT_EXPECT_FALSE(test, validate_dyn_buf(&param, near_top, 3 * PAGE_SIZE));
+}
+
+/* Dynamic buffer: explicit multiplier scales the size, negative and overflowing counts fail */
+static void test_dyn_buf_multiplier_and_bad_count(struct kunit *test)
+{
+	struct kapi_param_spec param = {};
+	const void __user *near_top = (void __user *)(TASK_SIZE_MAX - 2 * PAGE_SIZE);
+
+	init_dyn_buf_param(&param);
+	param.size_multiplier = 8;
+
+	KUNIT_EXPECT_FALSE(test, validate_dyn_buf(&param, (void __user *)PAGE_SIZE,
+						  (s64)(SIZE_MAX / 8 + 1)));
+	KUNIT_EXPECT_FALSE(test, validate_dyn_buf(&param, (void __user *)PAGE_SIZE, -1));
+
+	if (!kapi_bounds_user_range(near_top))
+		kunit_skip(test, "access_ok() does not bound user ranges on this architecture");
+
+	KUNIT_EXPECT_TRUE(test, validate_dyn_buf(&param, near_top, PAGE_SIZE / 8));
+	KUNIT_EXPECT_FALSE(test, validate_dyn_buf(&param, near_top, PAGE_SIZE));
+}
+
+#endif /* CONFIG_KAPI_RUNTIME_CHECKS */
+
+static const struct kernel_api_spec kapi_test_macro_spec = {
+	.name = "test_macro_spec",
+	KAPI_SIGNAL_MASK_COUNT(1)
+	KAPI_SIGNAL_MASK(0, "blocked", "Signals blocked while waiting")
+		KAPI_SIGNAL_MASK_SIGNALS(SIGINT, SIGTERM, SIGQUIT)
+	},
+	KAPI_CAPABILITY_COUNT(1)
+	KAPI_CAPABILITY(0, CAP_SYS_ADMIN, "CAP_SYS_ADMIN", KAPI_CAP_BYPASS_CHECK)
+		KAPI_CAP_ALTERNATIVE(CAP_SYS_RESOURCE, CAP_NET_ADMIN)
+	},
+};
+
+/* Signal mask and capability alternative macros fill in their counts */
+static void test_signal_mask_and_cap_alternative(struct kunit *test)
+{
+	const struct kernel_api_spec *spec = &kapi_test_macro_spec;
+	char *buf;
+
+	KUNIT_EXPECT_EQ(test, spec->signal_mask_count, 1U);
+	KUNIT_EXPECT_EQ(test, spec->signal_masks[0].signal_count, 3U);
+	KUNIT_EXPECT_EQ(test, spec->signal_masks[0].signals[2], SIGQUIT);
+	KUNIT_EXPECT_EQ(test, spec->capabilities[0].alternative_count, 2U);
+	KUNIT_EXPECT_EQ(test, spec->capabilities[0].alternative[1], CAP_NET_ADMIN);
+
+	buf = kunit_kzalloc(test, 8192, GFP_KERNEL);
+	KUNIT_ASSERT_NOT_NULL(test, buf);
+	KUNIT_ASSERT_GT(test, kapi_export_json(spec, buf, 8192), 0);
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"blocked\""));
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"alternatives\""));
+}
+
+/* Unregister non-existent spec is a no-op */
+static void test_unregister_nonexistent(struct kunit *test)
+{
+	/* Should not crash or error */
+	kapi_unregister_spec("nonexistent_spec_xyz");
+}
+
+/* Multiple specs can be registered and looked up */
+static void test_multiple_specs(struct kunit *test)
+{
+	struct kernel_api_spec *spec1, *spec2;
+	const struct kernel_api_spec *found;
+
+	spec1 = kzalloc_obj(*spec1, GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec1);
+	spec2 = kzalloc_obj(*spec2, GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec2);
+
+	init_test_spec(spec1, "multi_spec_1");
+	init_test_spec(spec2, "multi_spec_2");
+
+	KUNIT_ASSERT_EQ(test, kapi_register_spec(spec1), 0);
+	KUNIT_ASSERT_EQ(test, kapi_register_spec(spec2), 0);
+
+	found = kapi_get_spec("multi_spec_1");
+	KUNIT_EXPECT_PTR_EQ(test, found, (const struct kernel_api_spec *)spec1);
+
+	found = kapi_get_spec("multi_spec_2");
+	KUNIT_EXPECT_PTR_EQ(test, found, (const struct kernel_api_spec *)spec2);
+
+	kapi_unregister_spec("multi_spec_1");
+	kapi_unregister_spec("multi_spec_2");
+	kfree(spec1);
+	kfree(spec2);
+}
+
+/* JSON export produces valid output */
+static void test_json_export(struct kunit *test)
+{
+	static const s64 enum_vals[] = { 3, -1 };
+	static const s64 err_vals[] = { -EINVAL, -EFAULT };
+	struct kernel_api_spec *spec;
+	char *buf;
+	int ret;
+
+	spec = kzalloc_obj(*spec, GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+
+	buf = kzalloc(4096, GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, buf);
+
+	init_test_spec(spec, "test_json");
+	spec->param_count = 1;
+	spec->params[0].name = "arg0";
+	spec->params[0].type_name = "int";
+	spec->params[0].constraint_type = KAPI_CONSTRAINT_ENUM;
+	spec->params[0].enum_values = enum_vals;
+	spec->params[0].enum_count = ARRAY_SIZE(enum_vals);
+	spec->params[0].valid_mask = 0xff;
+	spec->params[0].size_param_idx = 2;
+	spec->return_spec.check_type = KAPI_RETURN_ERROR_CHECK;
+	spec->return_spec.error_values = err_vals;
+	spec->return_spec.error_count = ARRAY_SIZE(err_vals);
+
+	ret = kapi_export_json(spec, buf, 4096);
+	KUNIT_EXPECT_GT(test, ret, 0);
+
+	/* Verify it starts with '{' and ends with '}' */
+	KUNIT_EXPECT_EQ(test, buf[0], '{');
+	KUNIT_ASSERT_GT(test, ret, 1);
+	/* Find last non-whitespace char */
+	while (ret > 0 && (buf[ret - 1] == '\n' || buf[ret - 1] == ' '))
+		ret--;
+	KUNIT_EXPECT_EQ(test, buf[ret - 1], '}');
+
+	/* Verify key fields are present */
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"name\""));
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"test_json\""));
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"parameters\""));
+
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"constraint_type\": \"enum\""));
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"enum_values\": [3, -1]"));
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"valid_mask\": \"0xff\""));
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"size_param_idx\": 1"));
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"error_values\": [-22, -14]"));
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"state_transitions\""));
+	KUNIT_EXPECT_NOT_NULL(test, strstr(buf, "\"struct_specs\""));
+
+	kfree(buf);
+	kfree(spec);
+}
+
+/* JSON export with NULL args returns -EINVAL */
+static void test_json_export_null(struct kunit *test)
+{
+	struct kernel_api_spec *spec;
+	char buf[64];
+	int ret;
+
+	ret = kapi_export_json(NULL, buf, sizeof(buf));
+	KUNIT_EXPECT_EQ(test, ret, -EINVAL);
+
+	spec = kunit_kzalloc(test, sizeof(*spec), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+	init_test_spec(spec, "test");
+
+	ret = kapi_export_json(spec, NULL, 64);
+	KUNIT_EXPECT_EQ(test, ret, -EINVAL);
+
+	ret = kapi_export_json(spec, buf, 0);
+	KUNIT_EXPECT_EQ(test, ret, -EINVAL);
+}
+
+/* JSON export into a buffer that is too small reports -E2BIG */
+static void test_json_export_small_buffer(struct kunit *test)
+{
+	struct kernel_api_spec *spec;
+	char buf[64];
+	int ret;
+
+	spec = kunit_kzalloc(test, sizeof(*spec), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, spec);
+	init_test_spec(spec, "test_small");
+
+	ret = kapi_export_json(spec, buf, sizeof(buf));
+
+	KUNIT_EXPECT_EQ(test, ret, -E2BIG);
+}
+
+static struct kunit_case kapi_test_cases[] = {
+	KUNIT_CASE(test_register_valid),
+	KUNIT_CASE(test_lookup_registered),
+	KUNIT_CASE(test_double_register),
+	KUNIT_CASE(test_unregister),
+	KUNIT_CASE(test_get_spec_null),
+	KUNIT_CASE(test_register_null),
+	KUNIT_CASE(test_register_null_name),
+#ifdef CONFIG_KAPI_RUNTIME_CHECKS
+	KUNIT_CASE(test_constraint_range_valid),
+	KUNIT_CASE(test_constraint_range_invalid),
+	KUNIT_CASE(test_constraint_mask_valid),
+	KUNIT_CASE(test_constraint_mask_invalid),
+	KUNIT_CASE(test_constraint_power_of_two),
+	KUNIT_CASE(test_constraint_page_aligned),
+	KUNIT_CASE(test_constraint_nonzero),
+	KUNIT_CASE(test_return_validation),
+	KUNIT_CASE(test_return_known_error),
+	KUNIT_CASE(test_return_unknown_error),
+	KUNIT_CASE(test_constraint_alignment),
+	KUNIT_CASE(test_fd_int_overflow),
+	KUNIT_CASE(test_constraint_enum),
+	KUNIT_CASE(test_constraint_buffer),
+	KUNIT_CASE(test_return_range),
+	KUNIT_CASE(test_dyn_buf_zero_count),
+	KUNIT_CASE(test_dyn_buf_null_nonzero_count),
+	KUNIT_CASE(test_dyn_buf_kernel_addr),
+	KUNIT_CASE(test_dyn_buf_default_multiplier),
+	KUNIT_CASE(test_dyn_buf_multiplier_and_bad_count),
+#endif
+	KUNIT_CASE(test_signal_mask_and_cap_alternative),
+	KUNIT_CASE(test_unregister_nonexistent),
+	KUNIT_CASE(test_multiple_specs),
+	KUNIT_CASE(test_json_export),
+	KUNIT_CASE(test_json_export_null),
+	KUNIT_CASE(test_json_export_small_buffer),
+	{}
+};
+
+static struct kunit_suite kapi_test_suite = {
+	.name = "kapi",
+	.test_cases = kapi_test_cases,
+};
+
+kunit_test_suite(kapi_test_suite);
+
+MODULE_DESCRIPTION("KUnit tests for Kernel API Specification Framework");
+MODULE_LICENSE("GPL");
diff --git a/kernel/api/kernel_api_spec.c b/kernel/api/kernel_api_spec.c
new file mode 100644
index 0000000000000..c3b220104c04b
--- /dev/null
+++ b/kernel/api/kernel_api_spec.c
@@ -0,0 +1,1415 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+ *
+ * kernel_api_spec.c - Kernel API Specification Framework Implementation
+ *
+ * Provides runtime support for kernel API specifications including validation,
+ * export to various formats, and querying capabilities.
+ */
+
+#define pr_fmt(fmt) "kapi: " fmt
+
+#include <linux/kernel.h>
+#include <linux/kernel_api_spec.h>
+#include <linux/string.h>
+#include <linux/slab.h>
+#include <linux/list.h>
+#include <linux/mutex.h>
+#include <linux/export.h>
+#include <linux/preempt.h>
+#include <linux/hardirq.h>
+#include <linux/file.h>
+#include <linux/fdtable.h>
+#include <linux/uaccess.h>
+#include <linux/limits.h>
+#include <linux/fcntl.h>
+#include <linux/mm.h>
+#include <linux/ratelimit.h>
+
+#include "internal.h"
+
+/* Dynamic API registration */
+static LIST_HEAD(dynamic_api_specs);
+static DEFINE_MUTEX(api_spec_mutex);
+
+struct dynamic_api_spec {
+	struct list_head list;
+	const struct kernel_api_spec *spec;
+};
+
+/*
+ * __kapi_find_spec_locked - Internal lookup, caller must hold api_spec_mutex
+ */
+static const struct kernel_api_spec *__kapi_find_spec_locked(const char *name)
+{
+	const struct kernel_api_spec * const *pp;
+	struct dynamic_api_spec *dyn_spec;
+
+	for (pp = __start_kapi_specs; pp < __stop_kapi_specs; pp++) {
+		const struct kernel_api_spec *spec = *pp;
+
+		if (spec && spec->name && strcmp(spec->name, name) == 0)
+			return spec;
+	}
+
+	list_for_each_entry(dyn_spec, &dynamic_api_specs, list) {
+		if (dyn_spec->spec->name &&
+		    strcmp(dyn_spec->spec->name, name) == 0)
+			return dyn_spec->spec;
+	}
+
+	return NULL;
+}
+
+/**
+ * kapi_get_spec - Get API specification by name
+ * @name: Function name to look up
+ *
+ * Return: Pointer to the API specification, or NULL if not found. The
+ * pointer stays valid for specifications in the ``.kapi_specs`` ELF section
+ * (built-in, statically defined). A dynamically registered spec stays valid
+ * only until kapi_unregister_spec() is called for it, so the caller must
+ * serialize against unregistration.
+ *
+ * Context: May sleep. Do not call under spinlock or in IRQ context.
+ */
+const struct kernel_api_spec *kapi_get_spec(const char *name)
+{
+	const struct kernel_api_spec *spec;
+
+	if (!name)
+		return NULL;
+
+	mutex_lock(&api_spec_mutex);
+	spec = __kapi_find_spec_locked(name);
+	mutex_unlock(&api_spec_mutex);
+
+	return spec;
+}
+EXPORT_SYMBOL_GPL(kapi_get_spec);
+
+/**
+ * kapi_register_spec - Register a dynamic API specification
+ * @spec: API specification to register
+ *
+ * Return: 0 on success, negative error code on failure
+ */
+int kapi_register_spec(const struct kernel_api_spec *spec)
+{
+	struct dynamic_api_spec *dyn_spec;
+	int ret = 0;
+
+	if (!spec || !spec->name || !spec->name[0])
+		return -EINVAL;
+
+	dyn_spec = kzalloc_obj(*dyn_spec, GFP_KERNEL);
+	if (!dyn_spec)
+		return -ENOMEM;
+
+	dyn_spec->spec = spec;
+
+	mutex_lock(&api_spec_mutex);
+
+	/* Check if already exists while holding lock to prevent races */
+	if (__kapi_find_spec_locked(spec->name)) {
+		ret = -EEXIST;
+		kfree(dyn_spec);
+	} else {
+		list_add_tail(&dyn_spec->list, &dynamic_api_specs);
+	}
+
+	mutex_unlock(&api_spec_mutex);
+
+	return ret;
+}
+EXPORT_SYMBOL_GPL(kapi_register_spec);
+
+/**
+ * kapi_unregister_spec - Unregister a dynamic API specification
+ * @name: Name of API to unregister
+ */
+void kapi_unregister_spec(const char *name)
+{
+	struct dynamic_api_spec *dyn_spec, *tmp;
+
+	if (!name)
+		return;
+
+	mutex_lock(&api_spec_mutex);
+	list_for_each_entry_safe(dyn_spec, tmp, &dynamic_api_specs, list) {
+		if (dyn_spec->spec->name &&
+		    strcmp(dyn_spec->spec->name, name) == 0) {
+			list_del(&dyn_spec->list);
+			kfree(dyn_spec);
+			break;
+		}
+	}
+	mutex_unlock(&api_spec_mutex);
+}
+EXPORT_SYMBOL_GPL(kapi_unregister_spec);
+
+/**
+ * kapi_param_type_to_string - Convert parameter type to string
+ * @type: Parameter type
+ *
+ * Return: String representation of type
+ */
+const char *kapi_param_type_to_string(enum kapi_param_type type)
+{
+	static const char * const type_names[] = {
+		[KAPI_TYPE_VOID] = "void",
+		[KAPI_TYPE_INT] = "int",
+		[KAPI_TYPE_UINT] = "uint",
+		[KAPI_TYPE_PTR] = "pointer",
+		[KAPI_TYPE_STRUCT] = "struct",
+		[KAPI_TYPE_UNION] = "union",
+		[KAPI_TYPE_ENUM] = "enum",
+		[KAPI_TYPE_FUNC_PTR] = "function_pointer",
+		[KAPI_TYPE_ARRAY] = "array",
+		[KAPI_TYPE_FD] = "file_descriptor",
+		[KAPI_TYPE_USER_PTR] = "user_pointer",
+		[KAPI_TYPE_PATH] = "pathname",
+		[KAPI_TYPE_CUSTOM] = "custom",
+	};
+
+	if (type >= ARRAY_SIZE(type_names))
+		return "unknown";
+
+	return type_names[type];
+}
+
+/**
+ * kapi_lock_type_to_string - Convert lock type to string
+ * @type: Lock type
+ *
+ * Return: String representation of lock type
+ */
+const char *kapi_lock_type_to_string(enum kapi_lock_type type)
+{
+	static const char * const lock_names[] = {
+		[KAPI_LOCK_NONE] = "none",
+		[KAPI_LOCK_MUTEX] = "mutex",
+		[KAPI_LOCK_SPINLOCK] = "spinlock",
+		[KAPI_LOCK_RWLOCK] = "rwlock",
+		[KAPI_LOCK_SEQLOCK] = "seqlock",
+		[KAPI_LOCK_RCU] = "rcu",
+		[KAPI_LOCK_SEMAPHORE] = "semaphore",
+		[KAPI_LOCK_CUSTOM] = "custom",
+	};
+
+	if (type >= ARRAY_SIZE(lock_names))
+		return "unknown";
+
+	return lock_names[type];
+}
+
+/**
+ * kapi_lock_scope_to_string - Convert lock scope to string
+ * @scope: Lock scope
+ *
+ * Return: String representation of lock scope
+ */
+const char *kapi_lock_scope_to_string(enum kapi_lock_scope scope)
+{
+	static const char * const scope_names[] = {
+		[KAPI_LOCK_INTERNAL] = "internal",
+		[KAPI_LOCK_ACQUIRES] = "acquires",
+		[KAPI_LOCK_RELEASES] = "releases",
+		[KAPI_LOCK_CALLER_HELD] = "caller_held",
+	};
+
+	if (scope >= ARRAY_SIZE(scope_names))
+		return "unknown";
+
+	return scope_names[scope];
+}
+
+/**
+ * return_check_type_to_string - Convert return check type to string
+ * @type: Return check type
+ *
+ * Return: String representation of return check type
+ */
+static const char *return_check_type_to_string(enum kapi_return_check_type type)
+{
+	static const char * const check_names[] = {
+		[KAPI_RETURN_EXACT] = "exact",
+		[KAPI_RETURN_RANGE] = "range",
+		[KAPI_RETURN_ERROR_CHECK] = "error_check",
+		[KAPI_RETURN_FD] = "file_descriptor",
+		[KAPI_RETURN_CUSTOM] = "custom",
+		[KAPI_RETURN_NO_RETURN] = "no_return",
+	};
+
+	if (type >= ARRAY_SIZE(check_names))
+		return "unknown";
+
+	return check_names[type];
+}
+
+/**
+ * capability_action_to_string - Convert capability action to string
+ * @action: Capability action
+ *
+ * Return: String representation of capability action
+ */
+static const char *capability_action_to_string(enum kapi_capability_action action)
+{
+	static const char * const action_names[] = {
+		[KAPI_CAP_BYPASS_CHECK] = "bypass_check",
+		[KAPI_CAP_INCREASE_LIMIT] = "increase_limit",
+		[KAPI_CAP_OVERRIDE_RESTRICTION] = "override_restriction",
+		[KAPI_CAP_GRANT_PERMISSION] = "grant_permission",
+		[KAPI_CAP_MODIFY_BEHAVIOR] = "modify_behavior",
+		[KAPI_CAP_ACCESS_RESOURCE] = "access_resource",
+		[KAPI_CAP_PERFORM_OPERATION] = "perform_operation",
+	};
+
+	if (action >= ARRAY_SIZE(action_names))
+		return "unknown";
+
+	return action_names[action];
+}
+
+/**
+ * constraint_type_to_string - Convert constraint type to string
+ * @type: Constraint type
+ *
+ * Return: String representation of constraint type
+ */
+static const char *constraint_type_to_string(enum kapi_constraint_type type)
+{
+	static const char * const constraint_names[] = {
+		[KAPI_CONSTRAINT_NONE] = "none",
+		[KAPI_CONSTRAINT_RANGE] = "range",
+		[KAPI_CONSTRAINT_MASK] = "mask",
+		[KAPI_CONSTRAINT_ENUM] = "enum",
+		[KAPI_CONSTRAINT_ALIGNMENT] = "alignment",
+		[KAPI_CONSTRAINT_POWER_OF_TWO] = "power_of_two",
+		[KAPI_CONSTRAINT_PAGE_ALIGNED] = "page_aligned",
+		[KAPI_CONSTRAINT_NONZERO] = "nonzero",
+		[KAPI_CONSTRAINT_USER_STRING] = "user_string",
+		[KAPI_CONSTRAINT_USER_PATH] = "user_path",
+		[KAPI_CONSTRAINT_USER_PTR] = "user_ptr",
+		[KAPI_CONSTRAINT_BUFFER] = "buffer",
+		[KAPI_CONSTRAINT_CUSTOM] = "custom",
+	};
+
+	if (type >= ARRAY_SIZE(constraint_names))
+		return "unknown";
+
+	return constraint_names[type];
+}
+
+/*
+ * kapi_json_escape - Write a JSON-escaped string into a buffer
+ * @buf: Output buffer
+ * @size: Remaining space in buffer
+ * @str: Input string to escape
+ *
+ * Escapes backslash, double-quote, and control characters for JSON output.
+ * Return: Number of bytes written (via scnprintf semantics)
+ */
+static int kapi_json_escape(char *buf, size_t size, const char *str)
+{
+	int ret = 0;
+	const char *p;
+
+	if (!str || size == 0)
+		return 0;
+
+	for (p = str; *p && ret < size - 1; p++) {
+		switch (*p) {
+		case '\\':
+			ret += scnprintf(buf + ret, size - ret, "\\\\");
+			break;
+		case '"':
+			ret += scnprintf(buf + ret, size - ret, "\\\"");
+			break;
+		case '\n':
+			ret += scnprintf(buf + ret, size - ret, "\\n");
+			break;
+		case '\r':
+			ret += scnprintf(buf + ret, size - ret, "\\r");
+			break;
+		case '\t':
+			ret += scnprintf(buf + ret, size - ret, "\\t");
+			break;
+		default:
+			if ((unsigned char)*p < 0x20) {
+				ret += scnprintf(buf + ret, size - ret,
+						 "\\u%04x", (unsigned char)*p);
+			} else {
+				ret += scnprintf(buf + ret, size - ret,
+						 "%c", *p);
+			}
+			break;
+		}
+	}
+
+	if (ret < size)
+		buf[ret] = '\0';
+
+	return ret;
+}
+
+static int kapi_json_str(char *buf, size_t size, const char *str)
+{
+	int ret = 0;
+
+	ret += scnprintf(buf, size, "\"");
+	ret += kapi_json_escape(buf + ret, size - ret, str);
+	ret += scnprintf(buf + ret, size - ret, "\"");
+	return ret;
+}
+
+static int kapi_json_s64_list(char *buf, size_t size, const s64 *vals, u32 count)
+{
+	int ret = scnprintf(buf, size, "[");
+	u32 i;
+
+	for (i = 0; vals && i < count; i++)
+		ret += scnprintf(buf + ret, size - ret, "%s%lld",
+				 i ? ", " : "", vals[i]);
+
+	ret += scnprintf(buf + ret, size - ret, "]");
+	return ret;
+}
+
+static int kapi_json_struct_spec(char *buf, size_t size,
+				 const struct kapi_struct_spec *st)
+{
+	int ret;
+	u32 i;
+
+	ret = scnprintf(buf, size, "    {\n      \"name\": ");
+	ret += kapi_json_str(buf + ret, size - ret, st->name);
+	ret += scnprintf(buf + ret, size - ret,
+		",\n      \"size\": %zu,\n      \"alignment\": %zu,\n      \"description\": ",
+		st->size, st->alignment);
+	ret += kapi_json_str(buf + ret, size - ret, st->description);
+	ret += scnprintf(buf + ret, size - ret, ",\n      \"fields\": [\n");
+
+	for (i = 0; i < st->field_count && i < KAPI_MAX_PARAMS; i++) {
+		const struct kapi_struct_field *field = &st->fields[i];
+
+		ret += scnprintf(buf + ret, size - ret,
+			"        {\n          \"name\": ");
+		ret += kapi_json_str(buf + ret, size - ret, field->name);
+		ret += scnprintf(buf + ret, size - ret, ",\n          \"type\": ");
+		ret += kapi_json_str(buf + ret, size - ret, field->type_name);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n"
+			"          \"type_class\": \"%s\",\n"
+			"          \"offset\": %zu,\n"
+			"          \"size\": %zu,\n"
+			"          \"flags\": \"0x%x\",\n"
+			"          \"constraint_type\": \"%s\",\n"
+			"          \"min_value\": %lld,\n"
+			"          \"max_value\": %lld,\n"
+			"          \"valid_mask\": \"0x%llx\",\n"
+			"          \"enum_values\": ",
+			kapi_param_type_to_string(field->type),
+			field->offset, field->size, field->flags,
+			constraint_type_to_string(field->constraint_type),
+			field->min_value, field->max_value, field->valid_mask);
+		ret += kapi_json_str(buf + ret, size - ret, field->enum_values);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n          \"description\": ");
+		ret += kapi_json_str(buf + ret, size - ret, field->description);
+		ret += scnprintf(buf + ret, size - ret,
+			"\n        }%s\n",
+			(i < st->field_count - 1) ? "," : "");
+	}
+
+	ret += scnprintf(buf + ret, size - ret, "      ]\n    }");
+	return ret;
+}
+
+/**
+ * kapi_export_json - Export API specification to JSON format
+ * @spec: API specification to export
+ * @buf: Buffer to write JSON to
+ * @size: Size of buffer
+ *
+ * Return: Number of bytes written, -EINVAL on bad arguments, or -E2BIG
+ * if the output did not fit in @buf (the contents are then incomplete)
+ */
+int kapi_export_json(const struct kernel_api_spec *spec, char *buf, size_t size)
+{
+	int ret = 0;
+	int i, j;
+
+	if (!spec || !buf || size == 0)
+		return -EINVAL;
+
+	ret = scnprintf(buf, size, "{\n  \"name\": ");
+	ret += kapi_json_str(buf + ret, size - ret, spec->name);
+	ret += scnprintf(buf + ret, size - ret,
+			 ",\n  \"version\": %u,\n  \"description\": ",
+			 spec->version);
+	ret += kapi_json_str(buf + ret, size - ret, spec->description);
+	ret += scnprintf(buf + ret, size - ret, ",\n  \"long_description\": ");
+	ret += kapi_json_str(buf + ret, size - ret, spec->long_description);
+	ret += scnprintf(buf + ret, size - ret,
+			 ",\n  \"context_flags\": \"0x%x\",\n",
+			 spec->context_flags);
+
+	/* Parameters */
+	ret += scnprintf(buf + ret, size - ret, "  \"parameters\": [\n");
+
+	for (i = 0; i < spec->param_count && i < KAPI_MAX_PARAMS; i++) {
+		const struct kapi_param_spec *param = &spec->params[i];
+
+		ret += scnprintf(buf + ret, size - ret, "    {\n      \"name\": ");
+		ret += kapi_json_str(buf + ret, size - ret, param->name);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"type\": ");
+		ret += kapi_json_str(buf + ret, size - ret, param->type_name);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n      \"type_class\": \"%s\",\n      \"flags\": \"0x%x\",\n      \"description\": ",
+			kapi_param_type_to_string(param->type),
+			param->flags);
+		ret += kapi_json_str(buf + ret, size - ret, param->description);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n      \"constraint_type\": \"%s\",\n      \"constraint_desc\": ",
+			constraint_type_to_string(param->constraint_type));
+		ret += kapi_json_str(buf + ret, size - ret, param->constraints);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n"
+			"      \"min_value\": %lld,\n"
+			"      \"max_value\": %lld,\n"
+			"      \"valid_mask\": \"0x%llx\",\n"
+			"      \"enum_values\": ",
+			param->min_value, param->max_value, param->valid_mask);
+		ret += kapi_json_s64_list(buf + ret, size - ret,
+					  param->enum_values, param->enum_count);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n      \"size\": %zu,\n      \"alignment\": %zu,\n      \"size_param_idx\": ",
+			param->size, param->alignment);
+		if (param->size_param_idx > 0)
+			ret += scnprintf(buf + ret, size - ret, "%d",
+					 param->size_param_idx - 1);
+		else
+			ret += scnprintf(buf + ret, size - ret, "null");
+		ret += scnprintf(buf + ret, size - ret,
+			",\n      \"size_multiplier\": %zu\n    }%s\n",
+			param->size_multiplier,
+			(i < spec->param_count - 1) ? "," : "");
+	}
+
+	ret += scnprintf(buf + ret, size - ret, "  ],\n");
+
+	/* Return value */
+	ret += scnprintf(buf + ret, size - ret, "  \"return\": {\n    \"type\": ");
+	ret += kapi_json_str(buf + ret, size - ret, spec->return_spec.type_name);
+	ret += scnprintf(buf + ret, size - ret,
+		",\n"
+		"    \"type_class\": \"%s\",\n"
+		"    \"check_type\": \"%s\",\n"
+		"    \"success_value\": %lld,\n"
+		"    \"success_min\": %lld,\n"
+		"    \"success_max\": %lld,\n"
+		"    \"error_values\": ",
+		kapi_param_type_to_string(spec->return_spec.type),
+		return_check_type_to_string(spec->return_spec.check_type),
+		spec->return_spec.success_value,
+		spec->return_spec.success_min,
+		spec->return_spec.success_max);
+	ret += kapi_json_s64_list(buf + ret, size - ret,
+				  spec->return_spec.error_values,
+				  spec->return_spec.error_count);
+
+	ret += scnprintf(buf + ret, size - ret, ",\n    \"description\": ");
+	ret += kapi_json_str(buf + ret, size - ret, spec->return_spec.description);
+	ret += scnprintf(buf + ret, size - ret, "\n  },\n");
+
+	/* Errors */
+	ret += scnprintf(buf + ret, size - ret, "  \"errors\": [\n");
+
+	for (i = 0; i < spec->error_count && i < KAPI_MAX_ERRORS; i++) {
+		const struct kapi_error_spec *error = &spec->errors[i];
+
+		ret += scnprintf(buf + ret, size - ret,
+			"    {\n      \"code\": %d,\n      \"name\": ",
+			error->error_code);
+		ret += kapi_json_str(buf + ret, size - ret, error->name);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"condition\": ");
+		ret += kapi_json_str(buf + ret, size - ret, error->condition);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"description\": ");
+		ret += kapi_json_str(buf + ret, size - ret, error->description);
+		ret += scnprintf(buf + ret, size - ret,
+			"\n    }%s\n",
+			(i < spec->error_count - 1) ? "," : "");
+	}
+
+	ret += scnprintf(buf + ret, size - ret, "  ],\n");
+
+	/* Locks */
+	ret += scnprintf(buf + ret, size - ret, "  \"locks\": [\n");
+
+	for (i = 0; i < spec->lock_count && i < KAPI_MAX_LOCKS; i++) {
+		const struct kapi_lock_spec *lock = &spec->locks[i];
+
+		ret += scnprintf(buf + ret, size - ret, "    {\n      \"name\": ");
+		ret += kapi_json_str(buf + ret, size - ret, lock->lock_name);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n      \"type\": \"%s\",\n      \"scope\": \"%s\",\n      \"description\": ",
+			kapi_lock_type_to_string(lock->lock_type),
+			kapi_lock_scope_to_string(lock->scope));
+		ret += kapi_json_str(buf + ret, size - ret, lock->description);
+		ret += scnprintf(buf + ret, size - ret,
+			"\n    }%s\n",
+			(i < spec->lock_count - 1) ? "," : "");
+	}
+
+	ret += scnprintf(buf + ret, size - ret, "  ],\n");
+
+	/* Capabilities */
+	ret += scnprintf(buf + ret, size - ret, "  \"capabilities\": [\n");
+
+	for (i = 0; i < spec->capability_count && i < KAPI_MAX_CAPABILITIES; i++) {
+		const struct kapi_capability_spec *cap = &spec->capabilities[i];
+
+		ret += scnprintf(buf + ret, size - ret,
+			"    {\n      \"capability\": %d,\n      \"name\": ",
+			cap->capability);
+		ret += kapi_json_str(buf + ret, size - ret, cap->cap_name);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n      \"action\": \"%s\",\n      \"allows\": ",
+			capability_action_to_string(cap->action));
+		ret += kapi_json_str(buf + ret, size - ret, cap->allows);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"without_cap\": ");
+		ret += kapi_json_str(buf + ret, size - ret, cap->without_cap);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"check_condition\": ");
+		ret += kapi_json_str(buf + ret, size - ret, cap->check_condition);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"priority\": %u", cap->priority);
+
+		if (cap->alternative_count > 0) {
+			ret += scnprintf(buf + ret, size - ret,
+				",\n      \"alternatives\": [");
+			for (j = 0; j < cap->alternative_count && j < KAPI_MAX_CAPABILITIES; j++) {
+				ret += scnprintf(buf + ret, size - ret,
+					"%s%d", j ? ", " : "",
+					cap->alternative[j]);
+			}
+			ret += scnprintf(buf + ret, size - ret, "]");
+		}
+
+		ret += scnprintf(buf + ret, size - ret,
+			"\n    }%s\n",
+			(i < spec->capability_count - 1) ? "," : "");
+	}
+
+	ret += scnprintf(buf + ret, size - ret, "  ],\n");
+
+	/* Constraints */
+	ret += scnprintf(buf + ret, size - ret, "  \"constraints\": [\n");
+	for (i = 0; i < spec->constraint_count && i < KAPI_MAX_CONSTRAINTS; i++) {
+		const struct kapi_constraint_spec *con = &spec->constraints[i];
+
+		ret += scnprintf(buf + ret, size - ret, "    {\n      \"name\": ");
+		ret += kapi_json_str(buf + ret, size - ret, con->name);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"description\": ");
+		ret += kapi_json_str(buf + ret, size - ret, con->description);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"expression\": ");
+		ret += kapi_json_str(buf + ret, size - ret, con->expression);
+		ret += scnprintf(buf + ret, size - ret,
+			"\n    }%s\n",
+			(i < spec->constraint_count - 1) ? "," : "");
+	}
+	ret += scnprintf(buf + ret, size - ret, "  ],\n");
+
+	/* Signals */
+	ret += scnprintf(buf + ret, size - ret, "  \"signals\": [\n");
+	for (i = 0; i < spec->signal_count && i < KAPI_MAX_SIGNALS; i++) {
+		const struct kapi_signal_spec *sig = &spec->signals[i];
+
+		ret += scnprintf(buf + ret, size - ret,
+			"    {\n      \"signal_num\": %d,\n      \"signal_name\": ",
+			sig->signal_num);
+		ret += kapi_json_str(buf + ret, size - ret, sig->signal_name);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n      \"direction\": \"0x%x\",\n      \"action\": %u,\n      \"target\": ",
+			sig->direction, sig->action);
+		ret += kapi_json_str(buf + ret, size - ret, sig->target);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"condition\": ");
+		ret += kapi_json_str(buf + ret, size - ret, sig->condition);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"description\": ");
+		ret += kapi_json_str(buf + ret, size - ret, sig->description);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n"
+			"      \"restartable\": %s,\n"
+			"      \"sa_flags_required\": \"0x%x\",\n"
+			"      \"sa_flags_forbidden\": \"0x%x\",\n"
+			"      \"error_on_signal\": %d,\n"
+			"      \"transform_to\": %d,\n"
+			"      \"timing\": ",
+			sig->restartable ? "true" : "false",
+			sig->sa_flags_required,
+			sig->sa_flags_forbidden,
+			sig->error_on_signal,
+			sig->transform_to);
+		ret += kapi_json_str(buf + ret, size - ret, sig->timing);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n      \"priority\": %u,\n      \"interruptible\": %s,\n      \"queue_behavior\": ",
+			sig->priority,
+			sig->interruptible ? "true" : "false");
+		ret += kapi_json_str(buf + ret, size - ret, sig->queue_behavior);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n      \"state_required\": \"0x%x\",\n      \"state_forbidden\": \"0x%x\"\n    }%s\n",
+			sig->state_required,
+			sig->state_forbidden,
+			(i < spec->signal_count - 1) ? "," : "");
+	}
+	ret += scnprintf(buf + ret, size - ret, "  ],\n");
+
+	/* Side effects */
+	ret += scnprintf(buf + ret, size - ret, "  \"side_effects\": [\n");
+	for (i = 0; i < spec->side_effect_count && i < KAPI_MAX_SIDE_EFFECTS; i++) {
+		const struct kapi_side_effect *eff = &spec->side_effects[i];
+
+		ret += scnprintf(buf + ret, size - ret,
+			"    {\n      \"type\": \"0x%x\",\n      \"target\": ",
+			eff->type);
+		ret += kapi_json_str(buf + ret, size - ret, eff->target);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"condition\": ");
+		ret += kapi_json_str(buf + ret, size - ret, eff->condition);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"description\": ");
+		ret += kapi_json_str(buf + ret, size - ret, eff->description);
+		ret += scnprintf(buf + ret, size - ret,
+			",\n      \"reversible\": %s\n    }%s\n",
+			eff->reversible ? "true" : "false",
+			(i < spec->side_effect_count - 1) ? "," : "");
+	}
+	ret += scnprintf(buf + ret, size - ret, "  ],\n");
+
+	/* State transitions */
+	ret += scnprintf(buf + ret, size - ret, "  \"state_transitions\": [\n");
+	for (i = 0; i < spec->state_trans_count && i < KAPI_MAX_STATE_TRANS; i++) {
+		const struct kapi_state_transition *trans = &spec->state_transitions[i];
+
+		ret += scnprintf(buf + ret, size - ret, "    {\n      \"object\": ");
+		ret += kapi_json_str(buf + ret, size - ret, trans->object);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"from_state\": ");
+		ret += kapi_json_str(buf + ret, size - ret, trans->from_state);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"to_state\": ");
+		ret += kapi_json_str(buf + ret, size - ret, trans->to_state);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"condition\": ");
+		ret += kapi_json_str(buf + ret, size - ret, trans->condition);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"description\": ");
+		ret += kapi_json_str(buf + ret, size - ret, trans->description);
+		ret += scnprintf(buf + ret, size - ret,
+			"\n    }%s\n",
+			(i < spec->state_trans_count - 1) ? "," : "");
+	}
+	ret += scnprintf(buf + ret, size - ret, "  ],\n");
+
+	/* Signal masks */
+	ret += scnprintf(buf + ret, size - ret, "  \"signal_masks\": [\n");
+	for (i = 0; i < spec->signal_mask_count && i < KAPI_MAX_SIGNALS; i++) {
+		const struct kapi_signal_mask_spec *mask = &spec->signal_masks[i];
+
+		ret += scnprintf(buf + ret, size - ret, "    {\n      \"name\": ");
+		ret += kapi_json_str(buf + ret, size - ret, mask->mask_name);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"description\": ");
+		ret += kapi_json_str(buf + ret, size - ret, mask->description);
+		ret += scnprintf(buf + ret, size - ret, ",\n      \"signals\": [");
+		for (j = 0; j < mask->signal_count && j < KAPI_MAX_SIGNALS; j++)
+			ret += scnprintf(buf + ret, size - ret, "%s%d",
+					 j ? ", " : "", mask->signals[j]);
+		ret += scnprintf(buf + ret, size - ret,
+			"]\n    }%s\n",
+			(i < spec->signal_mask_count - 1) ? "," : "");
+	}
+	ret += scnprintf(buf + ret, size - ret, "  ],\n");
+
+	/* Structure specifications */
+	ret += scnprintf(buf + ret, size - ret, "  \"struct_specs\": [\n");
+	for (i = 0; i < spec->struct_spec_count && i < KAPI_MAX_STRUCT_SPECS; i++) {
+		ret += kapi_json_struct_spec(buf + ret, size - ret,
+					     &spec->struct_specs[i]);
+		ret += scnprintf(buf + ret, size - ret, "%s\n",
+				 (i < spec->struct_spec_count - 1) ? "," : "");
+	}
+	ret += scnprintf(buf + ret, size - ret, "  ],\n");
+
+	/* Additional info */
+	ret += scnprintf(buf + ret, size - ret, "  \"examples\": ");
+	ret += kapi_json_str(buf + ret, size - ret, spec->examples);
+	ret += scnprintf(buf + ret, size - ret, ",\n  \"notes\": ");
+	ret += kapi_json_str(buf + ret, size - ret, spec->notes);
+	ret += scnprintf(buf + ret, size - ret, "\n}\n");
+
+	/* scnprintf() never writes past size - 1, so a full buffer means truncation */
+	if (ret >= size - 1)
+		return -E2BIG;
+
+	return ret;
+}
+EXPORT_SYMBOL_GPL(kapi_export_json);
+
+#ifdef CONFIG_KAPI_RUNTIME_CHECKS
+
+/**
+ * kapi_validate_fd - Validate that a file descriptor value is in valid range
+ * @fd: File descriptor to validate
+ *
+ * Only the numeric range is checked: AT_FDCWD or a non-negative value.
+ * Openness is left to the syscall, since the fd can be closed between check
+ * and use. Other negative values are rejected here, so a syscall such as
+ * close() fails with EINVAL for them rather than EBADF.
+ *
+ * Return: true if fd is in valid range, false otherwise
+ */
+static bool kapi_validate_fd(int fd)
+{
+	return fd == AT_FDCWD || fd >= 0;
+}
+
+/**
+ * kapi_validate_user_ptr - Validate that a user pointer is accessible
+ * @ptr: User pointer to validate
+ * @size: Size in bytes to validate
+ *
+ * Return: true if user memory is accessible, false otherwise
+ */
+static bool kapi_validate_user_ptr(const void __user *ptr, size_t size)
+{
+	/* NULL pointers are not valid; caller handles optional case */
+	if (!ptr)
+		return false;
+
+	return access_ok(ptr, size);
+}
+
+/**
+ * kapi_validate_user_ptr_with_params - Validate user pointer with dynamic size
+ * @param_spec: Parameter specification
+ * @ptr: User pointer to validate
+ * @all_params: Array of all parameter values
+ * @param_count: Number of parameters
+ *
+ * Return: true if user memory is accessible, false otherwise
+ */
+static bool kapi_validate_user_ptr_with_params(const struct kapi_param_spec *param_spec,
+						const void __user *ptr,
+						const s64 *all_params,
+						int param_count)
+{
+	size_t actual_size;
+
+	/* NULL is allowed for optional parameters */
+	if (!ptr && (param_spec->flags & KAPI_PARAM_OPTIONAL))
+		return true;
+
+	/*
+	 * size_param_idx is stored 1-based (0 means "no dynamic sizing").
+	 * Convert to a real index before looking into all_params.
+	 */
+	if (param_spec->size_param_idx > 0 &&
+	    param_spec->size_param_idx - 1 < param_count) {
+		s64 count = all_params[param_spec->size_param_idx - 1];
+		size_t unit = param_spec->size_multiplier ?: 1;
+
+		if (count < 0) {
+			pr_warn_ratelimited("Parameter %s: size determinant is negative (%lld)\n",
+				param_spec->name, count);
+			return false;
+		}
+
+		/* A zero-length access never dereferences the pointer */
+		if (count == 0)
+			return true;
+
+		if (count > SIZE_MAX / unit) {
+			pr_warn_ratelimited("Parameter %s: size calculation overflow\n",
+				param_spec->name);
+			return false;
+		}
+
+		actual_size = (size_t)count * unit;
+	} else {
+		actual_size = param_spec->size;
+	}
+
+	return kapi_validate_user_ptr(ptr, actual_size);
+}
+
+/**
+ * kapi_validate_path - Validate that a pathname is accessible and within limits
+ * @path: User pointer to pathname
+ * @param_spec: Parameter specification
+ *
+ * Return: true if path is valid, false otherwise
+ */
+static bool kapi_validate_path(const char __user *path,
+				const struct kapi_param_spec *param_spec)
+{
+	size_t len;
+
+	/* NULL is allowed for optional parameters */
+	if (!path && (param_spec->flags & KAPI_PARAM_OPTIONAL))
+		return true;
+
+	if (!path) {
+		pr_warn_ratelimited("Parameter %s: NULL path not allowed\n", param_spec->name);
+		return false;
+	}
+
+	if (!access_ok(path, 1)) {
+		pr_warn_ratelimited("Parameter %s: path pointer %p not accessible\n",
+			param_spec->name, path);
+		return false;
+	}
+
+	/*
+	 * Use strnlen_user to check the path length and accessibility.
+	 * Note: strnlen_user() is subject to TOCTOU -- the measured length
+	 * may change if another thread modifies the user memory. This is
+	 * acceptable since the kernel re-copies and re-validates the path
+	 * later in the syscall path. This check is best-effort.
+	 */
+	len = strnlen_user(path, PATH_MAX + 1);
+	if (len == 0) {
+		pr_warn_ratelimited("Parameter %s: invalid path pointer %p\n",
+			param_spec->name, path);
+		return false;
+	}
+
+	if (len > PATH_MAX) {
+		pr_warn_ratelimited("Parameter %s: path too long (exceeds PATH_MAX)\n",
+			param_spec->name);
+		return false;
+	}
+
+	return true;
+}
+
+/**
+ * kapi_validate_user_string - Validate a userspace null-terminated string
+ * @str: User pointer to string
+ * @param_spec: Parameter specification containing length constraints
+ *
+ * Validates that the userspace string pointer is accessible and that the
+ * string length (excluding null terminator) is within the range specified
+ * by min_value and max_value in the parameter specification.
+ *
+ * Return: true if string is valid, false otherwise
+ */
+static bool kapi_validate_user_string(const char __user *str,
+				       const struct kapi_param_spec *param_spec)
+{
+	size_t len;
+	size_t max_check_len;
+
+	/* NULL is allowed for optional parameters */
+	if (!str && (param_spec->flags & KAPI_PARAM_OPTIONAL))
+		return true;
+
+	if (!str) {
+		pr_warn_ratelimited("Parameter %s: NULL string not allowed\n", param_spec->name);
+		return false;
+	}
+
+	if (!access_ok(str, 1)) {
+		pr_warn_ratelimited("Parameter %s: string pointer %p not accessible\n",
+			param_spec->name, str);
+		return false;
+	}
+
+	/*
+	 * Use strnlen_user to check the string length and validate accessibility.
+	 * Check up to max_value + 1 to detect strings that are too long.
+	 * If max_value is 0 or unset, use PATH_MAX as a reasonable default.
+	 *
+	 * Note: strnlen_user() is subject to TOCTOU -- see comment in
+	 * kapi_validate_path() above. This check is best-effort.
+	 */
+	max_check_len = param_spec->max_value > 0 ?
+			(size_t)param_spec->max_value + 1 : PATH_MAX + 1;
+	len = strnlen_user(str, max_check_len);
+
+	if (len == 0) {
+		pr_warn_ratelimited("Parameter %s: invalid string pointer %p\n",
+			param_spec->name, str);
+		return false;
+	}
+
+	/*
+	 * strnlen_user returns the length including the null terminator.
+	 * Convert to string length (excluding terminator) for range check.
+	 */
+	len--;
+
+	if (param_spec->min_value > 0 && len < (size_t)param_spec->min_value) {
+		pr_warn_ratelimited("Parameter %s: string too short (%zu < %lld)\n",
+			param_spec->name, len, param_spec->min_value);
+		return false;
+	}
+
+	if (param_spec->max_value > 0 && len > (size_t)param_spec->max_value) {
+		pr_warn_ratelimited("Parameter %s: string too long (%zu > %lld)\n",
+			param_spec->name, len, param_spec->max_value);
+		return false;
+	}
+
+	return true;
+}
+
+/**
+ * kapi_validate_user_ptr_constraint - Validate a userspace pointer with size
+ * @ptr: User pointer to validate
+ * @param_spec: Parameter specification containing size
+ *
+ * Validates that the userspace pointer is accessible and that the memory
+ * region of the specified size can be accessed. The size is taken from
+ * the param_spec->size field.
+ *
+ * Return: true if pointer is valid, false otherwise
+ */
+static bool kapi_validate_user_ptr_constraint(const void __user *ptr,
+					       const struct kapi_param_spec *param_spec)
+{
+	/* NULL is allowed for optional parameters */
+	if (!ptr && (param_spec->flags & KAPI_PARAM_OPTIONAL))
+		return true;
+
+	if (!ptr) {
+		pr_warn_ratelimited("Parameter %s: NULL pointer not allowed\n", param_spec->name);
+		return false;
+	}
+
+	if (param_spec->size == 0) {
+		pr_warn_ratelimited("Parameter %s: size not specified for user pointer validation\n",
+			param_spec->name);
+		return false;
+	}
+
+	if (!access_ok(ptr, param_spec->size)) {
+		pr_warn_ratelimited("Parameter %s: user pointer %p not accessible for %zu bytes\n",
+			param_spec->name, ptr, param_spec->size);
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * check_user_ptr is false once the pointer was validated against its dynamic
+ * size; the fixed-size check would reject NULL even when that size is 0.
+ */
+static bool kapi_validate_param_checks(const struct kapi_param_spec *param_spec,
+				       s64 value, bool check_user_ptr)
+{
+	int i;
+
+	/* Special handling for file descriptor type */
+	if (param_spec->type == KAPI_TYPE_FD &&
+	    !(param_spec->flags & KAPI_PARAM_OPTIONAL)) {
+		if (value < INT_MIN || value > INT_MAX) {
+			pr_warn_ratelimited("Parameter %s: file descriptor %lld out of int range\n",
+				param_spec->name, value);
+			return false;
+		}
+		if (!kapi_validate_fd((int)value)) {
+			pr_warn_ratelimited("Parameter %s: invalid file descriptor %lld\n",
+				param_spec->name, value);
+			return false;
+		}
+	}
+
+	/* Special handling for user pointer type */
+	if (check_user_ptr && param_spec->type == KAPI_TYPE_USER_PTR) {
+		const void __user *ptr = (const void __user *)(unsigned long)value;
+
+		/* NULL is allowed for optional parameters */
+		if (!ptr && (param_spec->flags & KAPI_PARAM_OPTIONAL))
+			return true;
+
+		if (!kapi_validate_user_ptr(ptr, param_spec->size)) {
+			pr_warn_ratelimited("Parameter %s: invalid user pointer %p (size: %zu)\n",
+				param_spec->name, ptr, param_spec->size);
+			return false;
+		}
+	}
+
+	/* Special handling for path type */
+	if (param_spec->type == KAPI_TYPE_PATH) {
+		const char __user *path = (const char __user *)(unsigned long)value;
+
+		if (!kapi_validate_path(path, param_spec))
+			return false;
+	}
+
+	switch (param_spec->constraint_type) {
+	case KAPI_CONSTRAINT_NONE:
+	case KAPI_CONSTRAINT_BUFFER:
+		return true;
+
+	case KAPI_CONSTRAINT_RANGE:
+		/*
+		 * If max_value is below min_value, it was likely set from an
+		 * unsigned constant (e.g. SIZE_MAX) that overflowed s64.  Treat
+		 * as no upper bound; only check the minimum.
+		 */
+		if (param_spec->max_value >= param_spec->min_value) {
+			if (value < param_spec->min_value ||
+			    value > param_spec->max_value) {
+				pr_warn_ratelimited("Parameter %s value %lld out of range [%lld, %lld]\n",
+					param_spec->name, value,
+					param_spec->min_value,
+					param_spec->max_value);
+				return false;
+			}
+		} else {
+			if (value < param_spec->min_value) {
+				pr_warn_ratelimited("Parameter %s value %lld below minimum %lld\n",
+					param_spec->name, value,
+					param_spec->min_value);
+				return false;
+			}
+		}
+		return true;
+
+	case KAPI_CONSTRAINT_MASK:
+		if (value & ~param_spec->valid_mask) {
+			pr_warn_ratelimited("Parameter %s value 0x%llx contains invalid bits (valid mask: 0x%llx)\n",
+				param_spec->name, value, param_spec->valid_mask);
+			return false;
+		}
+		return true;
+
+	case KAPI_CONSTRAINT_ENUM:
+		if (!param_spec->enum_values || param_spec->enum_count == 0)
+			return true;
+
+		for (i = 0; i < param_spec->enum_count; i++) {
+			if (value == param_spec->enum_values[i])
+				return true;
+		}
+		pr_warn_ratelimited("Parameter %s value %lld not in valid enumeration\n",
+			param_spec->name, value);
+		return false;
+
+	case KAPI_CONSTRAINT_ALIGNMENT:
+		if (param_spec->alignment == 0) {
+			pr_warn_ratelimited("Parameter %s: alignment constraint specified but alignment is 0\n",
+				param_spec->name);
+			return false;
+		}
+		if (param_spec->alignment & (param_spec->alignment - 1)) {
+			pr_warn_ratelimited("Parameter %s: alignment %zu is not a power of two\n",
+				param_spec->name, param_spec->alignment);
+			return false;
+		}
+		if (value & (param_spec->alignment - 1)) {
+			pr_warn_ratelimited("Parameter %s value 0x%llx not aligned to %zu boundary\n",
+				param_spec->name, value, param_spec->alignment);
+			return false;
+		}
+		return true;
+
+	case KAPI_CONSTRAINT_POWER_OF_TWO:
+		if (value == 0 || (value & (value - 1))) {
+			pr_warn_ratelimited("Parameter %s value %lld is not a power of two\n",
+				param_spec->name, value);
+			return false;
+		}
+		return true;
+
+	case KAPI_CONSTRAINT_PAGE_ALIGNED:
+		if (value & (PAGE_SIZE - 1)) {
+			pr_warn_ratelimited("Parameter %s value 0x%llx not page-aligned (PAGE_SIZE=%ld)\n",
+				param_spec->name, value, PAGE_SIZE);
+			return false;
+		}
+		return true;
+
+	case KAPI_CONSTRAINT_NONZERO:
+		if (value == 0) {
+			pr_warn_ratelimited("Parameter %s must be non-zero\n", param_spec->name);
+			return false;
+		}
+		return true;
+
+	case KAPI_CONSTRAINT_USER_STRING:
+		return kapi_validate_user_string((const char __user *)(unsigned long)value,
+						 param_spec);
+
+	case KAPI_CONSTRAINT_USER_PATH:
+		return kapi_validate_path((const char __user *)(unsigned long)value, param_spec);
+
+	case KAPI_CONSTRAINT_USER_PTR:
+		return kapi_validate_user_ptr_constraint((const void __user *)(unsigned long)value,
+							 param_spec);
+
+	case KAPI_CONSTRAINT_CUSTOM:
+		if (param_spec->validate)
+			return param_spec->validate(value);
+		return true;
+
+	default:
+		return true;
+	}
+}
+
+/**
+ * kapi_validate_param - Validate a parameter against its specification
+ * @param_spec: Parameter specification
+ * @value: Parameter value to validate
+ *
+ * Return: true if valid, false otherwise
+ */
+bool kapi_validate_param(const struct kapi_param_spec *param_spec, s64 value)
+{
+	return kapi_validate_param_checks(param_spec, value, true);
+}
+EXPORT_SYMBOL_GPL(kapi_validate_param);
+
+/**
+ * kapi_validate_param_with_context - Validate parameter with access to all params
+ * @param_spec: Parameter specification
+ * @value: Parameter value to validate
+ * @all_params: Array of all parameter values
+ * @param_count: Number of parameters
+ *
+ * Return: true if valid, false otherwise
+ */
+bool kapi_validate_param_with_context(const struct kapi_param_spec *param_spec,
+				       s64 value, const s64 *all_params, int param_count)
+{
+	/* Special handling for user pointer type with dynamic sizing */
+	if (param_spec->type == KAPI_TYPE_USER_PTR) {
+		const void __user *ptr = (const void __user *)(unsigned long)value;
+
+		/* NULL is allowed for optional parameters */
+		if (!ptr && (param_spec->flags & KAPI_PARAM_OPTIONAL))
+			return true;
+
+		if (!kapi_validate_user_ptr_with_params(param_spec, ptr, all_params, param_count)) {
+			pr_warn_ratelimited("Parameter %s: invalid user pointer %p\n",
+				param_spec->name, ptr);
+			return false;
+		}
+		return kapi_validate_param_checks(param_spec, value, false);
+	}
+
+	/* For other types, fall back to regular validation */
+	return kapi_validate_param(param_spec, value);
+}
+EXPORT_SYMBOL_GPL(kapi_validate_param_with_context);
+
+/**
+ * kapi_validate_syscall_params - Validate all syscall parameters together
+ * @spec: API specification
+ * @params: Array of parameter values
+ * @param_count: Number of parameters
+ *
+ * Return: -EINVAL if any parameter is invalid, 0 if all valid
+ */
+int kapi_validate_syscall_params(const struct kernel_api_spec *spec,
+				 const s64 *params, int param_count)
+{
+	int i;
+
+	if (!spec || !params)
+		return 0;
+
+	/* Validate that we have the expected number of parameters */
+	if (param_count != spec->param_count) {
+		pr_warn_ratelimited("API %s: parameter count mismatch (expected %u, got %d)\n",
+			spec->name, spec->param_count, param_count);
+		return -EINVAL;
+	}
+
+	/* Validate each parameter with context */
+	for (i = 0; i < spec->param_count && i < KAPI_MAX_PARAMS; i++) {
+		const struct kapi_param_spec *param_spec = &spec->params[i];
+
+		if (!kapi_validate_param_with_context(param_spec, params[i], params, param_count)) {
+			if (strncmp(spec->name, "sys_", 4) == 0) {
+				/* For syscalls, we can return EINVAL to userspace */
+				return -EINVAL;
+			}
+		}
+	}
+
+	return 0;
+}
+EXPORT_SYMBOL_GPL(kapi_validate_syscall_params);
+
+/**
+ * kapi_check_return_success - Check if return value indicates success
+ * @return_spec: Return specification
+ * @retval: Return value to check
+ *
+ * Return: true if the return value indicates success according to the spec.
+ */
+bool kapi_check_return_success(const struct kapi_return_spec *return_spec, s64 retval)
+{
+	u32 i;
+
+	if (!return_spec)
+		return true;
+
+	switch (return_spec->check_type) {
+	case KAPI_RETURN_EXACT:
+		return retval == return_spec->success_value;
+
+	case KAPI_RETURN_RANGE:
+		return retval >= return_spec->success_min &&
+		       retval <= return_spec->success_max;
+
+	case KAPI_RETURN_ERROR_CHECK:
+		/* Success if NOT in error list */
+		if (return_spec->error_values) {
+			for (i = 0; i < return_spec->error_count; i++) {
+				if (retval == return_spec->error_values[i])
+					return false;
+			}
+		}
+		return true;
+
+	case KAPI_RETURN_FD:
+		/* File descriptors: >= 0 is success, < 0 is error */
+		return retval >= 0;
+
+	case KAPI_RETURN_CUSTOM:
+		if (return_spec->is_success)
+			return return_spec->is_success(retval);
+		fallthrough;
+
+	default:
+		return true;
+	}
+}
+EXPORT_SYMBOL_GPL(kapi_check_return_success);
+
+/**
+ * kapi_validate_return_value - Validate that return value matches spec
+ * @spec: API specification
+ * @retval: Return value to validate
+ *
+ * Return: false if the spec is a KAPI_RETURN_FD check and a successful @retval
+ * is not a valid file descriptor, true otherwise.
+ *
+ * kapi_check_return_success() runs first. A value that does not satisfy it is
+ * treated as an error and is accepted. An error code that is not listed in the
+ * spec is only logged with pr_debug().
+ */
+bool kapi_validate_return_value(const struct kernel_api_spec *spec, s64 retval)
+{
+	int i;
+	bool is_success;
+
+	if (!spec)
+		return true; /* No spec means we can't validate */
+
+	/* First check if this is a success return */
+	is_success = kapi_check_return_success(&spec->return_spec, retval);
+
+	if (is_success) {
+		/* Special validation for file descriptor returns */
+		if (spec->return_spec.check_type == KAPI_RETURN_FD) {
+			if (retval > INT_MAX || !kapi_validate_fd((int)retval)) {
+				pr_warn_ratelimited("API %s returned invalid file descriptor %lld\n",
+					spec->name, retval);
+				return false;
+			}
+		}
+		return true;
+	}
+
+	if (spec->error_count == 0) {
+		pr_debug("API %s returned unspecified error %lld\n",
+			 spec->name, retval);
+		return true;
+	}
+
+	for (i = 0; i < spec->error_count && i < KAPI_MAX_ERRORS; i++) {
+		if (retval == spec->errors[i].error_code)
+			return true;
+	}
+
+	/*
+	 * Error not in spec - log at debug level since filesystem-specific and
+	 * device-specific error codes may not be exhaustively listed.
+	 */
+	pr_debug("API %s returned error code %lld not listed in spec\n",
+		 spec->name, retval);
+
+	return true;
+}
+EXPORT_SYMBOL_GPL(kapi_validate_return_value);
+
+/**
+ * kapi_validate_syscall_return - Validate syscall return value
+ * @spec: API specification
+ * @retval: Return value
+ *
+ * Return: always 0. A return value that does not match the spec is only
+ * logged and the syscall result is left unchanged.
+ */
+int kapi_validate_syscall_return(const struct kernel_api_spec *spec, s64 retval)
+{
+	if (!spec)
+		return 0;
+
+	/* Skip return validation if return spec was not defined */
+	if (spec->return_magic != KAPI_MAGIC_RETURN)
+		return 0;
+
+	if (!kapi_validate_return_value(spec, retval)) {
+		/* Log the violation but don't change the return value */
+		pr_warn_ratelimited("KAPI: Syscall %s returned unspecified value %lld\n",
+				    spec->name, retval);
+	}
+
+	return 0;
+}
+EXPORT_SYMBOL_GPL(kapi_validate_syscall_return);
+
+/**
+ * kapi_check_context - Check if current context matches API requirements
+ * @spec: API specification to check against
+ */
+void kapi_check_context(const struct kernel_api_spec *spec)
+{
+	bool valid = false;
+	u32 ctx;
+
+	if (!spec)
+		return;
+
+	ctx = spec->context_flags;
+
+	if (!ctx)
+		return;
+
+	/* Check if we're in an allowed context */
+	if ((ctx & KAPI_CTX_PROCESS) && !in_interrupt())
+		valid = true;
+
+	if ((ctx & KAPI_CTX_SOFTIRQ) && in_softirq())
+		valid = true;
+
+	if ((ctx & KAPI_CTX_HARDIRQ) && in_hardirq())
+		valid = true;
+
+	if ((ctx & KAPI_CTX_NMI) && in_nmi())
+		valid = true;
+
+	if (!valid)
+		WARN_ONCE(1, "API %s called from invalid context\n", spec->name);
+
+	/* Check specific requirements */
+	if ((ctx & KAPI_CTX_ATOMIC) && preemptible())
+		WARN_ONCE(1, "API %s requires atomic context\n", spec->name);
+
+	if ((ctx & KAPI_CTX_SLEEPABLE) && !preemptible())
+		WARN_ONCE(1, "API %s requires sleepable context\n", spec->name);
+}
+EXPORT_SYMBOL_GPL(kapi_check_context);
+
+#endif /* CONFIG_KAPI_RUNTIME_CHECKS */
-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* [PATCH v5 02/11] kernel/api: enable kerneldoc-based API specifications
  2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 01/11] kernel/api: introduce kernel API specification framework Sasha Levin
@ 2026-10-08  8:49 ` Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 03/11] kernel/api: add debugfs interface for kernel " Sasha Levin
                   ` (8 subsequent siblings)
  10 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

Add support for extracting API specifications from kernel-doc comments
and generating C macro invocations for the kernel API specification
framework.

Changes include:
- New kdoc_apispec.py module for generating API spec macros
- Updates to tools/docs/kernel-doc to support the -apispec output format
- Build system integration in Makefile.build
- Support for API-specific sections in kernel-doc comments

kernel-doc recognises the KAPI sections only in -apispec mode, so its
other output formats are unchanged. With CONFIG_KAPI_SPEC=y, Kbuild
generates <file>.apispec.h for each built source that has a contexts:
or context-flags: line plus another KAPI section, and force-includes it
when compiling that source.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 .gitignore                                  |    1 +
 Documentation/dev-tools/kernel-api-spec.rst |   24 +
 Makefile                                    |    1 +
 scripts/Makefile.build                      |   26 +
 tools/docs/kernel-doc                       |   12 +-
 tools/lib/python/kdoc/kdoc_apispec.py       | 1391 +++++++++++++++++++
 tools/lib/python/kdoc/kdoc_files.py         |   12 +-
 tools/lib/python/kdoc/kdoc_parser.py        |  101 +-
 8 files changed, 1558 insertions(+), 10 deletions(-)
 create mode 100644 tools/lib/python/kdoc/kdoc_apispec.py

diff --git a/.gitignore b/.gitignore
index 9875120ea7bde..37e6cd1d83fc9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@
 #
 .*
 *.a
+*.apispec.h
 *.asn1.[ch]
 *.bc
 *.bin
diff --git a/Documentation/dev-tools/kernel-api-spec.rst b/Documentation/dev-tools/kernel-api-spec.rst
index 4fb412b2720d3..03b32d3718c8e 100644
--- a/Documentation/dev-tools/kernel-api-spec.rst
+++ b/Documentation/dev-tools/kernel-api-spec.rst
@@ -180,6 +180,19 @@ DSL reference:
   from ``range:``.  The function-call
   form populates the matching aux fields
   (``range:`` / ``valid-mask:`` / ``size-param:``).
+* ``arch-mask:`` — extends a ``mask(...)`` constraint with bits that
+  are valid only on a named architecture.  The form is
+  ``arch-mask: <arch> = <bits-expr>`` and may appear multiple times.
+  The named arch must be one of the known short names
+  (``alpha``, ``arc``, ``arm``, ``arm64``, ``csky``, ``hexagon``,
+  ``loongarch``, ``m68k``, ``microblaze``, ``mips``, ``nios2``,
+  ``openrisc``, ``parisc``, ``powerpc``, ``riscv``, ``s390``, ``sh``,
+  ``sparc``, ``um``, ``x86``, ``xtensa``); the generator translates it
+  to the matching ``CONFIG_*`` symbol and emits the bits inside an
+  ``#ifdef`` so a single generated apispec.h compiles on every
+  architecture and folds in the arch-specific bits at compile time.
+  Use this for PROT/MAP bits whose UAPI symbols are defined only on
+  the matching arch.
 * ``lock: … type:`` accepts ``mutex``, ``spinlock``, ``rwlock``,
   ``seqlock``, ``rcu``, ``semaphore``, ``custom`` or ``KAPI_LOCK_*``.
 * ``signal: … direction:`` accepts ``receive``, ``send``, ``handle``,
@@ -266,6 +279,17 @@ The execution context recorded in a specification is not checked at runtime.
 The option is available on x86 and on architectures that use the generic
 ``__SYSCALL_DEFINEx()``.
 
+.. warning::
+
+   Userspace errno is affected when this option is on. For syscalls that
+   violate their parameter specification, KAPI short-circuits the call and
+   returns ``-EINVAL`` from the validator **before** the real handler runs.
+   That errno can differ from what the real handler would have produced for
+   the same condition (for example, ``-ENOMEM`` from an allocation path or
+   ``-EFAULT`` from a deeper copy-in). ``CONFIG_KAPI_RUNTIME_CHECKS`` is a
+   debug-only option; do not enable it on production kernels or in
+   userspace-visible test environments where error-code fidelity matters.
+
 Custom Validators
 -----------------
 
diff --git a/Makefile b/Makefile
index 7c855e6fe5448..fd7917f78fa45 100644
--- a/Makefile
+++ b/Makefile
@@ -2251,6 +2251,7 @@ clean: $(clean-dirs)
 		-o -name '*.ll' \
 		-o -name '*.gcno' \
 		-o -name '*.long-type-*.txt' \
+		-o -name '*.apispec.h' \
 		\) -type f -print \
 		-o -name '.tmp_*' -print \
 		| xargs rm -rf
diff --git a/scripts/Makefile.build b/scripts/Makefile.build
index 4349108e75e1f..6fa9ae47fb2c8 100644
--- a/scripts/Makefile.build
+++ b/scripts/Makefile.build
@@ -175,6 +175,32 @@ ifneq ($(KBUILD_EXTRA_WARN),)
 endif
 endif
 
+ifeq ($(CONFIG_KAPI_SPEC),y)
+# kernel-doc -apispec emits a spec only for comments with two or more KAPI
+# sections; pick files with a contexts: (or context-flags:) line and one more
+# KAPI section.
+has-apispec = $(if $(strip $(1)),$(shell \
+	grep -lE '^[[:space:]]*\*[[:space:]]*(contexts|context-flags):' $(1) 2>/dev/null | \
+	xargs -r grep -lE '^[[:space:]]*\*[[:space:]]*(api-type|param|error|capability|signal|lock|state-trans|constraint|side-effect|long-desc):'))
+apispec-c-files := $(call has-apispec, \
+	$(patsubst $(obj)/%.o,$(src)/%.c, \
+		$(filter-out %/built-in.a,$(real-obj-y))))
+apispec-y := $(patsubst $(src)/%.c,$(obj)/%.apispec.h,$(apispec-c-files))
+always-y += $(apispec-y)
+targets += $(apispec-y)
+
+quiet_cmd_apispec = APISPEC $@
+      cmd_apispec = PYTHONDONTWRITEBYTECODE=1 $(PYTHON3) $(KERNELDOC) -apispec \
+                    $(KDOCFLAGS) $< > $@
+
+$(obj)/%.apispec.h: $(src)/%.c $(KERNELDOC) \
+		$(wildcard $(srctree)/tools/lib/python/kdoc/*.py) FORCE
+	$(call if_changed,apispec)
+
+$(apispec-y:.apispec.h=.o): $(obj)/%.o: $(obj)/%.apispec.h
+$(apispec-y:.apispec.h=.o): private c_flags += -include $(obj)/$*.apispec.h
+endif
+
 # Compile C sources (.c)
 # ---------------------------------------------------------------------------
 
diff --git a/tools/docs/kernel-doc b/tools/docs/kernel-doc
index d9192c3f1645e..4c45c0d6cfa94 100755
--- a/tools/docs/kernel-doc
+++ b/tools/docs/kernel-doc
@@ -250,6 +250,8 @@ def main():
                          help="Output reStructuredText format (default).")
     out_fmt.add_argument("-N", "-none", "--none", action="store_true",
                          help="Do not output documentation, only warnings.")
+    out_fmt.add_argument("-apispec", "--apispec", action="store_true",
+                         help="Output C macro invocations for kernel API specifications.")
 
     out_fmt.add_argument("-y", "--yaml-file", "--yaml",
                          help="Stores kernel-doc output on a yaml file.")
@@ -326,6 +328,7 @@ def main():
     #
     from kdoc.kdoc_files import KernelFiles             # pylint: disable=C0415
     from kdoc.kdoc_output import RestFormat, ManFormat  # pylint: disable=C0415
+    from kdoc.kdoc_apispec import ApiSpecFormat         # pylint: disable=C0415
 
     yaml_content = set()
     if args.yaml_file:
@@ -351,12 +354,16 @@ def main():
             out_style = None
             n_outputs += 1
 
+        if args.apispec:
+            out_style = ApiSpecFormat()
+            n_outputs += 1
+
         if  args.rst or n_outputs == 0:
             n_outputs += 1
             out_style = RestFormat()
 
         if n_outputs > 1:
-             parser.error("Those arguments are muttually exclusive: --man, --rst, --none, except when generating a YAML file.")
+             parser.error("Those arguments are muttually exclusive: --man, --rst, --none, --apispec, except when generating a YAML file.")
 
         elif not n_outputs:
             out_style = RestFormat()
@@ -365,7 +372,8 @@ def main():
                          yaml_file=args.yaml_file, yaml_content=yaml_content,
                          out_style=out_style, werror=args.werror,
                          wreturn=args.wreturn, wshort_desc=args.wshort_desc,
-                         wcontents_before_sections=args.wcontents_before_sections)
+                         wcontents_before_sections=args.wcontents_before_sections,
+                         apispec=args.apispec)
 
     kfiles.parse(args.files, export_file=args.export_file)
 
diff --git a/tools/lib/python/kdoc/kdoc_apispec.py b/tools/lib/python/kdoc/kdoc_apispec.py
new file mode 100644
index 0000000000000..fb8e243a7b77d
--- /dev/null
+++ b/tools/lib/python/kdoc/kdoc_apispec.py
@@ -0,0 +1,1391 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+"""
+Generate C macro invocations for kernel API specifications from kernel-doc comments.
+
+This module creates C header files with API specification macros that match
+the kernel API specification framework in include/linux/kernel_api_spec.h.
+"""
+
+from kdoc.kdoc_output import OutputFormat
+import re
+import sys
+
+
+# Valid KAPI effect types
+VALID_EFFECT_TYPES = {
+    'KAPI_EFFECT_NONE', 'KAPI_EFFECT_MODIFY_STATE', 'KAPI_EFFECT_PROCESS_STATE',
+    'KAPI_EFFECT_IRREVERSIBLE', 'KAPI_EFFECT_SCHEDULE', 'KAPI_EFFECT_FILESYSTEM',
+    'KAPI_EFFECT_HARDWARE', 'KAPI_EFFECT_ALLOC_MEMORY', 'KAPI_EFFECT_FREE_MEMORY',
+    'KAPI_EFFECT_SIGNAL_SEND', 'KAPI_EFFECT_FILE_POSITION', 'KAPI_EFFECT_LOCK_ACQUIRE',
+    'KAPI_EFFECT_LOCK_RELEASE', 'KAPI_EFFECT_RESOURCE_CREATE', 'KAPI_EFFECT_RESOURCE_DESTROY',
+    'KAPI_EFFECT_NETWORK'
+}
+
+# DSL aliases mapping short tokens to their canonical KAPI_* C
+# identifier. Unknown tokens pass through unchanged.
+_CTX_ALIASES = {
+    'process':          'KAPI_CTX_PROCESS',
+    'softirq':          'KAPI_CTX_SOFTIRQ',
+    'hardirq':          'KAPI_CTX_HARDIRQ',
+    'nmi':              'KAPI_CTX_NMI',
+    'atomic':           'KAPI_CTX_ATOMIC',
+    'sleepable':        'KAPI_CTX_SLEEPABLE',
+    'preempt_disabled': 'KAPI_CTX_PREEMPT_DISABLED',
+    'irq_disabled':     'KAPI_CTX_IRQ_DISABLED',
+}
+_TYPE_ALIASES = {
+    'int':      'KAPI_TYPE_INT',
+    'uint':     'KAPI_TYPE_UINT',
+    'ptr':      'KAPI_TYPE_PTR',
+    'struct':   'KAPI_TYPE_STRUCT',
+    'union':    'KAPI_TYPE_UNION',
+    'enum':     'KAPI_TYPE_ENUM',
+    'func_ptr': 'KAPI_TYPE_FUNC_PTR',
+    'array':    'KAPI_TYPE_ARRAY',
+    'fd':       'KAPI_TYPE_FD',
+    'user_ptr': 'KAPI_TYPE_USER_PTR',
+    'uptr':     'KAPI_TYPE_USER_PTR',
+    'path':     'KAPI_TYPE_PATH',
+    'custom':   'KAPI_TYPE_CUSTOM',
+}
+_FLAG_ALIASES = {
+    'input':    'KAPI_PARAM_IN',
+    'in':       'KAPI_PARAM_IN',
+    'output':   'KAPI_PARAM_OUT',
+    'out':      'KAPI_PARAM_OUT',
+    'inout':    'KAPI_PARAM_INOUT',
+    'optional': 'KAPI_PARAM_OPTIONAL',
+    'const':    'KAPI_PARAM_CONST',
+    'volatile': 'KAPI_PARAM_VOLATILE',
+    'user':     'KAPI_PARAM_USER',
+    'dma':      'KAPI_PARAM_DMA',
+    'aligned':  'KAPI_PARAM_ALIGNED',
+}
+
+
+def _canon_token(tok, table):
+    """Look up `tok` (case-insensitive) in `table`. Unknown tokens
+    pass through verbatim."""
+    t = tok.strip()
+    if not t:
+        return ''
+    return table.get(t.lower(), t)
+
+
+def _canon_context_expr(expr):
+    """Canonicalise a context flag expression. Accepts '|'- or
+    ','-joined tokens; returns a '|'-joined string of KAPI_CTX_*
+    identifiers ready for KAPI_CONTEXT()."""
+    if not expr:
+        return expr
+    sep = ',' if ',' in expr and '|' not in expr else '|'
+    tokens = [_canon_token(t, _CTX_ALIASES) for t in expr.split(sep)]
+    return ' | '.join(t for t in tokens if t)
+
+
+def _canon_flags_expr(expr):
+    """Canonicalise a parameter flags expression. Accepts '|'- or
+    ','-joined KAPI_PARAM_* tokens or their aliases; returns a
+    '|'-joined canonical string."""
+    if not expr:
+        return expr
+    sep = ',' if ',' in expr and '|' not in expr else '|'
+    tokens = [_canon_token(t, _FLAG_ALIASES) for t in expr.split(sep)]
+    return ' | '.join(t for t in tokens if t)
+
+
+# Alias tables for enum families used as block-attribute values
+# (lock type, signal direction/action/timing, return check type) and
+# for the top-level `side-effect:` bitmask.
+_LOCK_TYPE_ALIASES = {
+    'none':      'KAPI_LOCK_NONE',
+    'mutex':     'KAPI_LOCK_MUTEX',
+    'spinlock':  'KAPI_LOCK_SPINLOCK',
+    'rwlock':    'KAPI_LOCK_RWLOCK',
+    'seqlock':   'KAPI_LOCK_SEQLOCK',
+    'rcu':       'KAPI_LOCK_RCU',
+    'semaphore': 'KAPI_LOCK_SEMAPHORE',
+    'custom':    'KAPI_LOCK_CUSTOM',
+}
+_SIGNAL_DIR_ALIASES = {
+    'receive': 'KAPI_SIGNAL_RECEIVE',
+    'send':    'KAPI_SIGNAL_SEND',
+    'handle':  'KAPI_SIGNAL_HANDLE',
+    'block':   'KAPI_SIGNAL_BLOCK',
+    'ignore':  'KAPI_SIGNAL_IGNORE',
+}
+_SIGNAL_ACTION_ALIASES = {
+    'default':   'KAPI_SIGNAL_ACTION_DEFAULT',
+    'terminate': 'KAPI_SIGNAL_ACTION_TERMINATE',
+    'coredump':  'KAPI_SIGNAL_ACTION_COREDUMP',
+    'stop':      'KAPI_SIGNAL_ACTION_STOP',
+    'continue':  'KAPI_SIGNAL_ACTION_CONTINUE',
+    'custom':    'KAPI_SIGNAL_ACTION_CUSTOM',
+    'return':    'KAPI_SIGNAL_ACTION_RETURN',
+    'restart':   'KAPI_SIGNAL_ACTION_RESTART',
+    'queue':     'KAPI_SIGNAL_ACTION_QUEUE',
+    'discard':   'KAPI_SIGNAL_ACTION_DISCARD',
+    'transform': 'KAPI_SIGNAL_ACTION_TRANSFORM',
+}
+_SIGNAL_TIMING_ALIASES = {
+    'before': 'KAPI_SIGNAL_TIME_BEFORE',
+    'during': 'KAPI_SIGNAL_TIME_DURING',
+    'after':  'KAPI_SIGNAL_TIME_AFTER',
+}
+_EFFECT_ALIASES = {
+    'none':             'KAPI_EFFECT_NONE',
+    'alloc_memory':     'KAPI_EFFECT_ALLOC_MEMORY',
+    'free_memory':      'KAPI_EFFECT_FREE_MEMORY',
+    'modify_state':     'KAPI_EFFECT_MODIFY_STATE',
+    'signal_send':      'KAPI_EFFECT_SIGNAL_SEND',
+    'file_position':    'KAPI_EFFECT_FILE_POSITION',
+    'lock_acquire':     'KAPI_EFFECT_LOCK_ACQUIRE',
+    'lock_release':     'KAPI_EFFECT_LOCK_RELEASE',
+    'resource_create':  'KAPI_EFFECT_RESOURCE_CREATE',
+    'resource_destroy': 'KAPI_EFFECT_RESOURCE_DESTROY',
+    'schedule':         'KAPI_EFFECT_SCHEDULE',
+    'hardware':         'KAPI_EFFECT_HARDWARE',
+    'network':          'KAPI_EFFECT_NETWORK',
+    'filesystem':       'KAPI_EFFECT_FILESYSTEM',
+    'process_state':    'KAPI_EFFECT_PROCESS_STATE',
+    'irreversible':     'KAPI_EFFECT_IRREVERSIBLE',
+}
+_RETURN_CHECK_ALIASES = {
+    'exact':       'KAPI_RETURN_EXACT',
+    'range':       'KAPI_RETURN_RANGE',
+    'error_check': 'KAPI_RETURN_ERROR_CHECK',
+    'fd':          'KAPI_RETURN_FD',
+    'custom':      'KAPI_RETURN_CUSTOM',
+    'no_return':   'KAPI_RETURN_NO_RETURN',
+}
+
+# Mapping from short architecture name (as used under arch/<name>/) to the
+# kernel CONFIG_* symbol that selects that architecture. Used by the
+# `arch-mask:` DSL form to wrap arch-specific mask bits in #ifdef so a
+# single generated apispec.h compiles on every architecture and folds
+# the right bits into the mask at compile time.
+_ARCH_CONFIG = {
+    'alpha':       'CONFIG_ALPHA',
+    'arc':         'CONFIG_ARC',
+    'arm':         'CONFIG_ARM',
+    'arm64':       'CONFIG_ARM64',
+    'csky':        'CONFIG_CSKY',
+    'hexagon':     'CONFIG_HEXAGON',
+    'loongarch':   'CONFIG_LOONGARCH',
+    'm68k':        'CONFIG_M68K',
+    'microblaze':  'CONFIG_MICROBLAZE',
+    'mips':        'CONFIG_MIPS',
+    'nios2':       'CONFIG_NIOS2',
+    'openrisc':    'CONFIG_OPENRISC',
+    'parisc':      'CONFIG_PARISC',
+    'powerpc':     'CONFIG_PPC',
+    'riscv':       'CONFIG_RISCV',
+    's390':        'CONFIG_S390',
+    'sh':          'CONFIG_SUPERH',
+    'sparc':       'CONFIG_SPARC',
+    'um':          'CONFIG_UML',
+    'x86':         'CONFIG_X86',
+    'xtensa':      'CONFIG_XTENSA',
+}
+
+
+def _canon_bitmask_expr(expr, table):
+    """Canonicalise a bitmask expression (e.g. signal direction/timing,
+    side-effect flags). Accepts `|`- or `,`-joined tokens and returns a
+    `|`-joined canonical KAPI_* string."""
+    if not expr:
+        return expr
+    sep = ',' if ',' in expr and '|' not in expr else '|'
+    tokens = [_canon_token(t, table) for t in expr.split(sep)]
+    return ' | '.join(t for t in tokens if t)
+
+
+# Types that carry user-space pointer semantics. A param with one of
+# these types implicitly gets KAPI_PARAM_USER.
+_IMPLIES_USER_FLAG = {'KAPI_TYPE_USER_PTR', 'KAPI_TYPE_PATH'}
+
+
+def _split_type_line(value):
+    """Split a 'type:' line into (type, [flags...]).
+
+    Accepts a single-token value (e.g. 'KAPI_TYPE_UINT' or 'uint')
+    leaving flags empty, or a comma-separated form
+    (e.g. 'uint, input, user') where the first token is the type and
+    subsequent tokens are flag aliases.
+
+    When the type is user-space (user_ptr, path), KAPI_PARAM_USER is
+    added to the flags list if not already present."""
+    parts = [p.strip() for p in value.split(',') if p.strip()]
+    if not parts:
+        return None, []
+    ty = _canon_token(parts[0], _TYPE_ALIASES)
+    flags = [_canon_token(f, _FLAG_ALIASES) for f in parts[1:]]
+    if ty in _IMPLIES_USER_FLAG and 'KAPI_PARAM_USER' not in flags:
+        flags.append('KAPI_PARAM_USER')
+    return ty, flags
+
+
+def _split_constraint_expr(value):
+    """Parse a constraint expression into (canonical_type, extras).
+
+    Shapes:
+        NAME                              e.g. 'user_path', 'nonzero'
+        NAME ( ARG (, ARG)* )             e.g. 'range(0, 4096)', 'buffer(2)'
+
+    Returns None for free text. Otherwise returns
+    (constraint_type, {aux_field: value, ...}) where the aux fields map
+    onto the matching param-range / param-mask / param-size /
+    param-enum-values / param-constraint slots.
+    """
+    t = value.strip()
+    if not t:
+        return None
+    # Split NAME ( ARGS )
+    lp = t.find('(')
+    rp = t.rfind(')')
+    if lp > 0 and rp > lp:
+        name = t[:lp].strip()
+        args_raw = t[lp + 1:rp].strip()
+    elif lp < 0:
+        name = t
+        args_raw = None
+    else:
+        return None
+    # Bareword must be a single identifier; multi-word values are free text.
+    if not name or any(c.isspace() for c in name):
+        return None
+    key = name.lower()
+    table = {
+        'range':          ('KAPI_CONSTRAINT_RANGE',       'param-range'),
+        'mask':           ('KAPI_CONSTRAINT_MASK',        'param-mask'),
+        'enum':           ('KAPI_CONSTRAINT_ENUM',        'param-enum-values'),
+        'alignment':      ('KAPI_CONSTRAINT_ALIGNMENT',   'param-alignment'),
+        'align':          ('KAPI_CONSTRAINT_ALIGNMENT',   'param-alignment'),
+        'power_of_two':   ('KAPI_CONSTRAINT_POWER_OF_TWO', None),
+        'page_aligned':   ('KAPI_CONSTRAINT_PAGE_ALIGNED', None),
+        'nonzero':        ('KAPI_CONSTRAINT_NONZERO',      None),
+        'user_string':    ('KAPI_CONSTRAINT_USER_STRING',  'param-size'),
+        'user_path':      ('KAPI_CONSTRAINT_USER_PATH',    None),
+        'user_ptr':       ('KAPI_CONSTRAINT_USER_PTR',     None),
+        'buffer':         ('KAPI_CONSTRAINT_BUFFER',       'param-size-param'),
+        'custom':         ('KAPI_CONSTRAINT_CUSTOM',       'param-constraint'),
+    }
+    if key not in table:
+        return None
+    ctype, aux_key = table[key]
+    extras = {}
+    if aux_key and args_raw is not None:
+        extras[aux_key] = args_raw
+    return ctype, extras
+
+
+# Subfield names consumed per block type. An indented line opens a new
+# subfield only when it starts with one of these followed by ':'; any
+# other line continues the previous subfield.
+_SIGNAL_SUBFIELDS = frozenset({
+    'direction', 'action', 'condition', 'desc', 'errno', 'timing',
+    'priority', 'restartable', 'interruptible', 'number', 'target',
+    'queue', 'queue_behavior', 'transform', 'transform_to', 'transform-to',
+    'sa_flags_required', 'sa-flags-required',
+    'sa_flags_forbidden', 'sa-flags-forbidden',
+    'state_required', 'state-required',
+    'state_forbidden', 'state-forbidden',
+})
+_LOCK_SUBFIELDS = frozenset({
+    'type', 'scope', 'acquired', 'released', 'held-on-entry',
+    'held-on-exit', 'desc',
+})
+_CONSTRAINT_SUBFIELDS = frozenset({'desc', 'expr'})
+_SIDE_EFFECT_SUBFIELDS = frozenset({'target', 'desc', 'condition', 'reversible'})
+_STATE_TRANS_SUBFIELDS = frozenset({'object', 'from', 'to', 'condition', 'desc'})
+_CAPABILITY_SUBFIELDS = frozenset({
+    'type', 'allows', 'without', 'condition', 'priority', 'desc',
+})
+_RETURN_SUBFIELDS = frozenset({
+    'type', 'check-type', 'success', 'success-range', 'error-values', 'desc',
+})
+
+_RETURN_INT = r'-?(?:0[xX][0-9a-fA-F]+|0[bB][01]+|\d+)[uUlL]*'
+_RETURN_EXACT_RE = re.compile(rf'^(?:==?\s*)?({_RETURN_INT})$')
+_RETURN_RANGE_RE = re.compile(rf'^>=\s*({_RETURN_INT})$')
+
+
+def _fold_paragraphs(content):
+    """Fold free-form prose into paragraphs.
+
+    Blank lines separate paragraphs ("\\n\\n"); wrapped lines inside a
+    paragraph are joined with spaces, except that a line starting with
+    "- " always begins a new line so bullet lists survive."""
+    paragraphs = []
+    current = []
+    for line in content.split('\n'):
+        line = line.strip()
+        if not line:
+            if current:
+                paragraphs.append(current)
+                current = []
+        elif line.startswith('- ') or not current:
+            current.append(line)
+        else:
+            current[-1] += ' ' + line
+    if current:
+        paragraphs.append(current)
+    return '\n\n'.join('\n'.join(p) for p in paragraphs) or None
+
+
+def _fold_lines(content):
+    """Keep every line of a block on its own line.
+
+    The indentation shared by the continuation lines is removed so that
+    relative indentation (nested code) is preserved; runs of blank lines
+    collapse into one."""
+    lines = [line.rstrip() for line in content.expandtabs().split('\n')]
+    while lines and not lines[0]:
+        lines.pop(0)
+    while lines and not lines[-1]:
+        lines.pop()
+    if not lines:
+        return None
+
+    indents = [len(line) - len(line.lstrip()) for line in lines[1:] if line]
+    base = min(indents) if indents else 0
+
+    out = []
+    for line in lines:
+        if line:
+            line = line[min(base, len(line) - len(line.lstrip())):]
+        elif out and not out[-1]:
+            continue
+        out.append(line)
+    return '\n'.join(out)
+
+
+def _return_success_macro(check_type, value):
+    """Translate a return `success:` value into the macro matching the
+    check type, or None when the check type does not use one (or the
+    value is not a plain integer expression)."""
+    if check_type in ('', 'KAPI_RETURN_EXACT'):
+        m = _RETURN_EXACT_RE.match(value)
+        return f"KAPI_RETURN_SUCCESS({m.group(1)})" if m else None
+    if check_type == 'KAPI_RETURN_RANGE':
+        m = _RETURN_RANGE_RE.match(value)
+        return f"KAPI_RETURN_SUCCESS_RANGE({m.group(1) if m else 0}, S64_MAX)"
+    return None
+
+
+class ApiSpecFormat(OutputFormat):
+    """Generate C macro invocations for kernel API specifications"""
+
+    def __init__(self):
+        super().__init__()
+        self.header_written = False
+
+    def msg(self, fname, name, args):
+        """Handles a single entry from kernel-doc parser"""
+        if not self.header_written:
+            header = self._generate_header()
+            self.header_written = True
+        else:
+            header = ""
+
+        self.data = ""
+        result = super().msg(fname, name, args)
+        return header + (result if result else self.data)
+
+    def _generate_header(self):
+        """Generate the file header"""
+        return (
+            "/* SPDX-License-Identifier: GPL-2.0 */\n"
+            "/* Auto-generated from kerneldoc annotations - DO NOT EDIT */\n\n"
+            "#include <linux/capability.h>\n"
+            "#include <linux/errno.h>\n"
+            "#include <linux/fcntl.h>\n"
+            "#include <linux/kernel_api_spec.h>\n"
+            "#include <linux/signal.h>\n"
+            "#include <linux/stat.h>\n\n"
+        )
+
+    def _format_macro_param(self, value, multiline=False):
+        """Format a value for use in C macro parameter.
+
+        Every string field in the kernel structs is a `const char *`, so
+        there is no length limit. Newlines become spaces unless
+        `multiline` is set, in which case they are kept as "\\n" escapes.
+        """
+        if value is None:
+            return '""'
+        value = str(value).replace('\\', '\\\\').replace('"', '\\"')
+        value = value.replace('\t', ' ').replace('\r', '').replace('\0', '')
+        value = value.replace('\n', '\\n' if multiline else ' ')
+        return f'"{value}"'
+
+    def _get_section(self, sections, key):
+        """Get first line from sections, checking with and without @ prefix and case variants"""
+        for variant in [key, key.capitalize(), key.title()]:
+            for prefix in ['', '@']:
+                full_key = prefix + variant
+                if full_key in sections:
+                    content = sections[full_key].strip()
+                    # Return only first line to avoid mixing sections
+                    return content.split('\n')[0].strip() if content else ''
+        return None
+
+    def _get_raw_section(self, sections, key):
+        """Get full section content, checking with and without @ prefix and case variants"""
+        for variant in [key, key.capitalize(), key.title()]:
+            for prefix in ['', '@']:
+                full_key = prefix + variant
+                if full_key in sections:
+                    return sections[full_key]
+        return ''
+
+    def _get_multiline_section(self, sections, key, fold=_fold_paragraphs):
+        """Get a multi-line section, structured by `fold`.
+
+        This is used for fields like notes, long-desc, and examples that
+        can span multiple lines in the kerneldoc comment.
+        """
+        content = self._get_raw_section(sections, key)
+        return fold(content) if content else None
+
+    def _parse_indented_items(self, section_content, item_parser):
+        """Generic parser for indented items.
+
+        Args:
+            section_content: Raw section content
+            item_parser: Function that takes (lines, start_index) and returns (item, next_index)
+
+        Returns:
+            List of parsed items
+        """
+        if not section_content:
+            return []
+
+        items = []
+        lines = section_content.strip().split('\n')
+        i = 0
+
+        while i < len(lines):
+            if not lines[i].strip():
+                i += 1
+                continue
+
+            # Check if this is a main item (not indented)
+            if not lines[i].startswith((' ', '\t')):
+                item, i = item_parser(lines, i)
+                if item:
+                    items.append(item)
+            else:
+                i += 1
+
+        return items
+
+    def _parse_subfields(self, lines, start_idx, keys):
+        """Parse indented subfields starting from start_idx+1.
+
+        A line opens a new subfield only if it starts with one of `keys`
+        followed by ':'; every other line continues the previous one.
+        Blank lines inside the block are skipped when more indented
+        lines follow.
+
+        Returns: (dict of subfields, next index)
+        """
+        subfields = {}
+        i = start_idx + 1
+
+        current_key = None
+        while i < len(lines):
+            if not lines[i].strip():
+                nxt = i + 1
+                while nxt < len(lines) and not lines[nxt].strip():
+                    nxt += 1
+                if nxt == len(lines) or not lines[nxt].startswith((' ', '\t')):
+                    break
+                i = nxt
+                continue
+            if not lines[i].startswith((' ', '\t')):
+                break
+            line = lines[i].strip()
+            key, sep, value = line.partition(':')
+            key = key.strip()
+            if sep and key in keys:
+                current_key = key
+                subfields[current_key] = value.strip()
+            elif current_key:
+                subfields[current_key] = (subfields[current_key] + ' ' + line).strip()
+            i += 1
+
+        return subfields, i
+
+    def _parse_signal_item(self, lines, i):
+        """Parse a single signal specification"""
+        signal = {'name': lines[i].strip()}
+        subfields, next_i = self._parse_subfields(lines, i, _SIGNAL_SUBFIELDS)
+
+        # `direction` and `timing` are bitmasks of KAPI_SIGNAL_* /
+        # KAPI_SIGNAL_TIME_* values; `action` is a single
+        # KAPI_SIGNAL_ACTION_* enum. All three canonicalise aliases to
+        # their KAPI_* spelling.
+        raw_direction = subfields.get('direction', 'KAPI_SIGNAL_RECEIVE')
+        raw_action    = subfields.get('action',    'KAPI_SIGNAL_ACTION_RETURN')
+        raw_timing    = subfields.get('timing')
+        # `errno:` carries the signal's errno-on-return. The plain
+        # `error:` spelling cannot be used inside a signal block
+        # because kerneldoc promotes it to a top-level `error:` section.
+        signal.update({
+            'direction':     _canon_bitmask_expr(raw_direction, _SIGNAL_DIR_ALIASES),
+            'action':        _canon_token(raw_action, _SIGNAL_ACTION_ALIASES),
+            'condition':     subfields.get('condition'),
+            'desc':          subfields.get('desc'),
+            'error':         subfields.get('errno'),
+            'timing':        _canon_bitmask_expr(raw_timing, _SIGNAL_TIMING_ALIASES)
+                              if raw_timing else None,
+            'priority':      subfields.get('priority'),
+            'restartable':   subfields.get('restartable', '').lower() == 'yes',
+            'interruptible': subfields.get('interruptible', '').lower() == 'yes',
+            'number':        subfields.get('number', '0'),
+            # Additional struct fields. These are optional; if absent, no
+            # KAPI_SIGNAL_* macro is emitted and the field stays at its
+            # zero-initialised default.
+            'target':        subfields.get('target'),
+            'queue':         subfields.get('queue') or subfields.get('queue_behavior'),
+            'transform':     subfields.get('transform') or subfields.get('transform_to')
+                              or subfields.get('transform-to'),
+            'sa_flags_required':  subfields.get('sa_flags_required')
+                                    or subfields.get('sa-flags-required'),
+            'sa_flags_forbidden': subfields.get('sa_flags_forbidden')
+                                    or subfields.get('sa-flags-forbidden'),
+            'state_required':     subfields.get('state_required')
+                                    or subfields.get('state-required'),
+            'state_forbidden':    subfields.get('state_forbidden')
+                                    or subfields.get('state-forbidden'),
+        })
+
+        return signal, next_i
+
+    def _parse_error_item(self, lines, i):
+        """Parse a single error specification"""
+        line = lines[i].strip()
+
+        # Skip desc: lines
+        if line.startswith('desc:'):
+            return None, i + 1
+
+        # Check for error pattern
+        if not re.match(r'^[A-Z][A-Z0-9_]+,', line):
+            return None, i + 1
+
+        error = {'line': line, 'desc': ''}
+
+        # Look for desc: and condition: subfields
+        i += 1
+        desc_lines = []
+        while i < len(lines):
+            next_line = lines[i].strip()
+            if next_line.startswith('desc:'):
+                desc_lines.append(next_line[5:].strip())
+                i += 1
+            elif next_line.startswith('condition:'):
+                error['condition'] = next_line[10:].strip()
+                i += 1
+            elif not next_line:
+                break
+            elif not desc_lines and re.match(r'^[A-Z][A-Z0-9_]+,', next_line):
+                # New error entry, but only if we haven't started a desc block
+                break
+            else:
+                desc_lines.append(next_line)
+                i += 1
+
+        if desc_lines:
+            error['desc'] = ' '.join(desc_lines)
+
+        return error, i
+
+    def _parse_lock_item(self, lines, i):
+        """Parse a single lock specification.
+
+        Two shapes are accepted:
+          * inline `NAME, TYPE` on the main line; or
+          * `NAME` on the main line with `type:` as an indented
+            subfield.
+        Lock-type values are canonicalised to KAPI_LOCK_* spellings.
+        """
+        head = lines[i].strip()
+        if not head:
+            return None, i + 1
+
+        parts = head.split(',', 1)
+        subfields, next_i = self._parse_subfields(lines, i, _LOCK_SUBFIELDS)
+
+        name = parts[0].strip()
+        type_raw = (parts[1].strip() if len(parts) >= 2
+                    else subfields.get('type', '').strip())
+        if not name or not type_raw:
+            return None, next_i
+
+        lock = {
+            'name': name,
+            'type': _canon_token(type_raw, _LOCK_TYPE_ALIASES),
+        }
+
+        for field in ['acquired', 'released', 'held-on-entry', 'held-on-exit']:
+            if subfields.get(field, '').lower() in ('true', 'yes'):
+                lock[field] = True
+
+        lock['desc'] = subfields.get('desc', '')
+
+        return lock, next_i
+
+    def _parse_constraint_item(self, lines, i):
+        """Parse a single constraint specification"""
+        line = lines[i].strip()
+
+        # NAME, description form
+        if ',' in line:
+            parts = line.split(',', 1)
+            constraint = {
+                'name': parts[0].strip(),
+                'desc': parts[1].strip() if len(parts) > 1 else '',
+                'expr': None
+            }
+        else:
+            constraint = {'name': line, 'desc': '', 'expr': None}
+
+        subfields, next_i = self._parse_subfields(lines, i, _CONSTRAINT_SUBFIELDS)
+
+        if 'desc' in subfields:
+            constraint['desc'] = (constraint['desc'] + ' ' + subfields['desc']).strip()
+        constraint['expr'] = subfields.get('expr')
+
+        return constraint, next_i
+
+    def _parse_side_effect_item(self, lines, i):
+        """Parse a single side effect specification"""
+        line = lines[i].strip()
+
+        # Defaults for the subfield form
+        effect = {
+            'type': line,
+            'target': '',
+            'desc': '',
+            'condition': None,
+            'reversible': False
+        }
+
+        # Inline comma-separated form
+        if ',' in line:
+            # Handle condition and reversible flags
+            cond_match = re.search(r',\s*condition=([^,]+?)(?:\s*,\s*reversible=(yes|no)\s*)?$', line)
+            if cond_match:
+                effect['condition'] = cond_match.group(1).strip()
+                effect['reversible'] = cond_match.group(2) == 'yes'
+                line = line[:cond_match.start()]
+            elif ', reversible=yes' in line:
+                effect['reversible'] = True
+                line = line.replace(', reversible=yes', '')
+            elif ', reversible=no' in line:
+                line = line.replace(', reversible=no', '')
+
+            parts = line.split(',', 2)
+            if len(parts) >= 1:
+                effect['type'] = parts[0].strip()
+            if len(parts) >= 2:
+                effect['target'] = parts[1].strip()
+            if len(parts) >= 3:
+                effect['desc'] = parts[2].strip()
+        else:
+            # Multi-line format with subfields
+            subfields, next_i = self._parse_subfields(
+                lines, i, _SIDE_EFFECT_SUBFIELDS)
+            effect.update({
+                'target': subfields.get('target', ''),
+                'desc': subfields.get('desc', ''),
+                'condition': subfields.get('condition'),
+                'reversible': subfields.get('reversible', '').lower() == 'yes'
+            })
+            return effect, next_i
+
+        return effect, i + 1
+
+    def _parse_state_trans_item(self, lines, i):
+        """Parse a single state transition specification"""
+        line = lines[i].strip()
+
+        trans = {
+            'target': line,
+            'from': '',
+            'to': '',
+            'condition': '',
+            'desc': ''
+        }
+
+        # Inline comma-separated form
+        if ',' in line:
+            parts = line.split(',', 3)
+            if len(parts) >= 1:
+                trans['target'] = parts[0].strip()
+            if len(parts) >= 2:
+                trans['from'] = parts[1].strip()
+            if len(parts) >= 3:
+                trans['to'] = parts[2].strip()
+            if len(parts) >= 4:
+                trans['desc'] = parts[3].strip()
+            return trans, i + 1
+        else:
+            # Multi-line format with subfields
+            subfields, next_i = self._parse_subfields(
+                lines, i, _STATE_TRANS_SUBFIELDS)
+            trans.update({
+                'target': subfields.get('object', line),
+                'from': subfields.get('from', ''),
+                'to': subfields.get('to', ''),
+                'condition': subfields.get('condition', ''),
+                'desc': subfields.get('desc', '')
+            })
+            return trans, next_i
+
+    def _process_parameters(self, sections, parameterlist, parameterdescs, parametertypes):
+        """Process and output parameter specifications"""
+        param_count = len(parameterlist)
+        if param_count > 0:
+            self.data += f"\n\tKAPI_PARAM_COUNT({param_count})\n"
+
+        for param_idx, param in enumerate(parameterlist):
+            param_name = param.strip()
+            param_desc = parameterdescs.get(param_name, '').strip()
+            param_ctype = parametertypes.get(param_name, '')
+
+            # Parse parameter specifications
+            param_section = self._get_raw_section(sections, 'param')
+            param_specs = {}
+            if param_section:
+                param_specs = self._parse_param_spec(param_section, param_name)
+
+            self.data += f"\n\tKAPI_PARAM({param_idx}, {self._format_macro_param(param_name)}, "
+            self.data += f"{self._format_macro_param(param_ctype)}, {self._format_macro_param(param_desc)})\n"
+
+            # Add parameter attributes
+            for key, macro in [
+                ('param-type', 'KAPI_PARAM_TYPE'),
+                ('param-flags', 'KAPI_PARAM_FLAGS'),
+                ('param-size', 'KAPI_PARAM_SIZE'),
+                ('param-alignment', 'KAPI_PARAM_ALIGNMENT'),
+            ]:
+                if key in param_specs:
+                    self.data += f"\t\t{macro}({param_specs[key]})\n"
+
+            # Handle constraint type
+            if 'param-constraint-type' in param_specs:
+                ctype = param_specs['param-constraint-type']
+                self.data += f"\t\tKAPI_PARAM_CONSTRAINT_TYPE({ctype})\n"
+
+            # Handle range
+            if 'param-range' in param_specs and ',' in param_specs['param-range']:
+                min_val, max_val = param_specs['param-range'].split(',', 1)
+                self.data += f"\t\tKAPI_PARAM_RANGE({min_val.strip()}, {max_val.strip()})\n"
+
+            # Handle mask. If `arch-mask:` lines are present, fall back
+            # to a raw `.valid_mask = (...)` initializer so we can wrap
+            # arch-specific bits in #ifdef CONFIG_<ARCH> ... #endif, which
+            # is illegal inside a function-like macro argument.
+            if 'param-mask' in param_specs:
+                arch_masks = param_specs.get('param-arch-mask') or []
+                if arch_masks:
+                    base = param_specs['param-mask']
+                    self.data += f"\t\t.valid_mask = ({base})"
+                    for arch, bits in arch_masks:
+                        config = _ARCH_CONFIG.get(arch.lower())
+                        if config is None:
+                            sys.stderr.write(
+                                f"kdoc_apispec: unknown arch '{arch}' in "
+                                f"arch-mask: line; skipping\n")
+                            continue
+                        self.data += (f"\n#ifdef {config}\n"
+                                      f"\t\t\t| ({bits})\n"
+                                      f"#endif\n")
+                    self.data += "\t\t,\n"
+                else:
+                    self.data += (
+                        f"\t\tKAPI_PARAM_VALID_MASK("
+                        f"{param_specs['param-mask']})\n")
+
+            # Handle enum values
+            if 'param-enum-values' in param_specs:
+                self.data += f"\t\tKAPI_PARAM_ENUM_VALUES({param_specs['param-enum-values']})\n"
+
+            # Handle size parameter index
+            if 'param-size-param' in param_specs:
+                self.data += f"\t\tKAPI_PARAM_SIZE_PARAM({param_specs['param-size-param']})\n"
+
+            # Handle constraint description
+            if 'param-constraint' in param_specs:
+                self.data += f"\t\tKAPI_PARAM_CONSTRAINT({self._format_macro_param(param_specs['param-constraint'])})\n"
+
+            self.data += "\t},\n"
+
+    def _parse_param_spec(self, section_content, param_name):
+        """Parse parameter specifications from indented format"""
+        specs = {}
+        lines = section_content.strip().split('\n')
+        current_item = None
+
+        # Map to expected keys
+        field_map = {
+            'type': 'param-type',
+            'flags': 'param-flags',
+            'size': 'param-size',
+            'constraint-type': 'param-constraint-type',
+            'constraint': 'param-constraint',
+            'cdesc': 'param-constraint',
+            'range': 'param-range',
+            'mask': 'param-mask',
+            'valid-mask': 'param-mask',
+            'valid-values': 'param-enum-values',
+            'alignment': 'param-alignment',
+            'size-param': 'param-size-param',
+            'struct-type': 'param-struct-type',
+            'arch-mask': 'param-arch-mask',
+        }
+
+        i = 0
+        while i < len(lines):
+            line = lines[i]
+            if not line.strip():
+                i += 1
+                continue
+
+            # Check if this is our parameter (non-indented line)
+            if not line.startswith((' ', '\t')):
+                parts = line.strip().split(',', 1)
+                current_item = param_name if parts[0].strip() == param_name else None
+                if current_item and len(parts) > 1:
+                    specs['param-type'] = parts[1].strip()
+                i += 1
+            elif current_item == param_name:
+                # Parse subfield
+                stripped = line.strip()
+                if ':' in stripped:
+                    key, value = stripped.split(':', 1)
+                    key = key.strip()
+                    value = value.strip()
+
+                    # Collect continuation lines (indented lines without a colon that
+                    # defines a new key, i.e., lines that are pure continuations)
+                    i += 1
+                    while i < len(lines):
+                        next_line = lines[i]
+                        # Stop if we hit a non-indented line (new param)
+                        if next_line.strip() and not next_line.startswith((' ', '\t')):
+                            break
+                        next_stripped = next_line.strip()
+                        # Stop if we hit a new key (contains colon with known key prefix)
+                        if next_stripped and ':' in next_stripped:
+                            potential_key = next_stripped.split(':', 1)[0].strip()
+                            if potential_key in field_map or potential_key in ['type', 'desc']:
+                                break
+                        # This is a continuation line
+                        if next_stripped:
+                            value = value + ' ' + next_stripped
+                        i += 1
+
+                    if key in field_map:
+                        # Clean up the value - remove excessive whitespace
+                        value = ' '.join(value.split())
+                        mapped = field_map[key]
+                        if mapped == 'param-type':
+                            # Single token sets the type; additional
+                            # comma-separated tokens are flags OR'd
+                            # into param-flags.
+                            ty, extra_flags = _split_type_line(value)
+                            if ty:
+                                specs['param-type'] = ty
+                            if extra_flags:
+                                existing = specs.get('param-flags', '')
+                                merged = (existing + ' | ' if existing else '') \
+                                         + ' | '.join(extra_flags)
+                                specs['param-flags'] = merged
+                        elif mapped == 'param-flags':
+                            specs['param-flags'] = _canon_flags_expr(value)
+                        elif mapped == 'param-constraint-type':
+                            # Accepts a KAPI_CONSTRAINT_* token or a
+                            # function-call expression like
+                            # `range(0, 4096)` / `mask(0xff)` /
+                            # `buffer(2)` that also populates the
+                            # matching aux field.
+                            parsed = _split_constraint_expr(value)
+                            if parsed is not None:
+                                ctype, extras = parsed
+                                specs['param-constraint-type'] = ctype
+                                for aux_k, aux_v in extras.items():
+                                    specs[aux_k] = aux_v
+                            else:
+                                specs['param-constraint-type'] = value
+                        elif mapped == 'param-arch-mask':
+                            # `arch-mask: <arch> = <bits>`: multiple
+                            # entries accumulate into a list of
+                            # (arch, bits) tuples that the emitter
+                            # turns into per-arch #ifdef-guarded mask
+                            # contributions.
+                            if '=' in value:
+                                arch, bits = value.split('=', 1)
+                                specs.setdefault(mapped, []).append(
+                                    (arch.strip(), bits.strip()))
+                        else:
+                            specs[mapped] = value
+                else:
+                    i += 1
+            else:
+                i += 1
+
+        return specs
+
+    def _validate_effect_type(self, effect_type):
+        """Validate and normalize effect type"""
+        if 'KAPI_EFFECT_' in effect_type and effect_type not in VALID_EFFECT_TYPES:
+            if '|' in effect_type:
+                parts = [p.strip() for p in effect_type.split('|')]
+                valid_parts = []
+                for p in parts:
+                    if p in VALID_EFFECT_TYPES:
+                        valid_parts.append(p)
+                    else:
+                        print(f"warning: unrecognized effect type '{p}', "
+                              f"defaulting to KAPI_EFFECT_MODIFY_STATE", file=sys.stderr)
+                        valid_parts.append('KAPI_EFFECT_MODIFY_STATE')
+                return ' | '.join(valid_parts)
+            print(f"warning: unrecognized effect type '{effect_type}', "
+                  f"defaulting to KAPI_EFFECT_MODIFY_STATE", file=sys.stderr)
+            return 'KAPI_EFFECT_MODIFY_STATE'
+
+        return effect_type
+
+    def _has_api_spec(self, sections):
+        """Check if this function has an API specification.
+
+        Returns True if a `contexts:` or `context-flags:` section is present
+        together with at least one other KAPI section. Regular kernel-doc
+        comments that only use a common section name like 'return' or
+        'error' do not produce a spec.
+        """
+        context_keys = ['context-flags', 'contexts']
+        indicators = [
+            'api-type', 'param', 'error', 'capability', 'signal', 'lock',
+            'state-trans', 'constraint', 'side-effect', 'long-desc'
+        ]
+
+        def has_section(names):
+            return any(key.lower().startswith(name) or
+                       key.lower().startswith('@' + name)
+                       for key in sections.keys() for name in names)
+
+        return has_section(context_keys) and has_section(indicators)
+
+    def out_function(self, fname, name, args):
+        """Generate API spec for a function"""
+        function_name = args.get('function', name)
+        sections = args.sections if hasattr(args, 'sections') else args.get('sections', {})
+
+        if not self._has_api_spec(sections):
+            return
+
+        parameterlist = args.parameterlist if hasattr(args, 'parameterlist') else args.get('parameterlist', [])
+        parameterdescs = args.parameterdescs if hasattr(args, 'parameterdescs') else args.get('parameterdescs', {})
+        parametertypes = args.parametertypes if hasattr(args, 'parametertypes') else args.get('parametertypes', {})
+        purpose = args.get('purpose', '')
+
+        # Start macro invocation
+        self.data += f"DEFINE_KERNEL_API_SPEC({function_name})\n"
+
+        # Basic info
+        if purpose:
+            self.data += f"\tKAPI_DESCRIPTION({self._format_macro_param(purpose)})\n"
+
+        long_desc = self._get_multiline_section(sections, 'long-desc')
+        if long_desc:
+            self.data += f"\tKAPI_LONG_DESC({self._format_macro_param(long_desc, True)})\n"
+
+        # Context flags. `contexts:` and `context-flags:` both work;
+        # tokens canonicalise to KAPI_CTX_* for KAPI_CONTEXT().
+        context = (self._get_section(sections, 'contexts')
+                   or self._get_section(sections, 'context-flags'))
+        if context:
+            self.data += f"\tKAPI_CONTEXT({_canon_context_expr(context)})\n"
+
+        # Process parameters
+        self._process_parameters(sections, parameterlist, parameterdescs, parametertypes)
+
+        # Process return value
+        self._process_return(sections)
+
+        # Process errors
+        errors = self._parse_indented_items(
+            self._get_raw_section(sections, 'error'),
+            self._parse_error_item
+        )
+
+        if errors:
+            self.data += f"\n\tKAPI_ERROR_COUNT({len(errors)})\n"
+
+            for idx, error in enumerate(errors):
+                self._output_error(idx, error)
+
+        # Process signals
+        signals = self._parse_indented_items(
+            self._get_raw_section(sections, 'signal'),
+            self._parse_signal_item
+        )
+
+        if signals:
+            self.data += f"\n\tKAPI_SIGNAL_COUNT({len(signals)})\n"
+
+            for idx, signal in enumerate(signals):
+                self._output_signal(idx, signal)
+
+        # Process other specifications
+        self._process_locks(sections)
+        self._process_constraints(sections)
+        self._process_side_effects(sections)
+        self._process_state_transitions(sections)
+        self._process_capabilities(sections)
+
+        # Add examples and notes
+        for key, macro, fold in [
+            ('examples', 'KAPI_EXAMPLES', _fold_lines),
+            ('notes', 'KAPI_NOTES', _fold_paragraphs),
+        ]:
+            value = self._get_multiline_section(sections, key, fold)
+            if value:
+                self.data += f"\n\t{macro}({self._format_macro_param(value, True)})\n"
+
+        self.data += "\n};\n\n"
+
+    def _process_return(self, sections):
+        """Process the return value specification from kerneldoc annotations"""
+        raw = self._get_raw_section(sections, 'return')
+        if not raw:
+            return
+
+        # Parse subfields from the return section, handling continuation lines
+        lines = raw.strip().split('\n')
+        subfields = {}
+        current_key = None
+        for line in lines:
+            stripped = line.strip()
+            key, sep, value = stripped.partition(':')
+            key = key.strip()
+            if sep and key in _RETURN_SUBFIELDS:
+                current_key = key
+                subfields[current_key] = value.strip()
+            elif current_key and stripped:
+                # Continuation line
+                subfields[current_key] += ' ' + stripped
+
+        ret_type = subfields.get('type', '')
+        check_type = subfields.get('check-type', '')
+        desc = subfields.get('desc', '')
+        success = subfields.get('success', '')
+
+        if not ret_type and not desc:
+            return
+
+        # Canonicalise short aliases:
+        #   type: int             -> KAPI_TYPE_INT
+        #   check-type: fd        -> KAPI_RETURN_FD
+        # The type name itself is kept as written.
+        type_name = ret_type
+        if ret_type:
+            ret_type = _canon_token(ret_type, _TYPE_ALIASES)
+        if check_type:
+            check_type = _canon_token(check_type, _RETURN_CHECK_ALIASES)
+
+        self.data += f"\n\tKAPI_RETURN({self._format_macro_param(type_name)}, "
+        self.data += f"{self._format_macro_param(desc)})\n"
+
+        if ret_type:
+            self.data += f"\t\tKAPI_RETURN_TYPE({ret_type})\n"
+
+        if check_type:
+            self.data += f"\t\tKAPI_RETURN_CHECK_TYPE({check_type})\n"
+
+        if success:
+            macro = _return_success_macro(check_type, success)
+            if macro:
+                self.data += f"\t\t{macro}\n"
+            elif check_type in ('', 'KAPI_RETURN_EXACT'):
+                sys.stderr.write(
+                    f"kdoc_apispec: ignoring unsupported success value "
+                    f"'{success}' for an exact return check\n")
+
+        self.data += "\t},\n"
+
+    def _output_error(self, idx, error):
+        """Output a single error specification"""
+        # Format: NAME, description
+        parts = error['line'].split(',', 1)
+        if len(parts) < 2:
+            return
+
+        name = parts[0].strip()
+        short_desc = parts[1].strip()
+        code = f"-{name}"
+
+        condition = error.get('condition') or short_desc
+        long_desc = error.get('desc', '') or short_desc
+
+        self.data += f"\n\tKAPI_ERROR({idx}, {code}, {self._format_macro_param(name)}, "
+        self.data += f"{self._format_macro_param(condition)},\n\t\t   {self._format_macro_param(long_desc)})\n"
+
+    def _output_signal(self, idx, signal):
+        """Output a single signal specification"""
+        self.data += f"\n\tKAPI_SIGNAL({idx}, {signal['number']}, "
+        self.data += f"{self._format_macro_param(signal['name'])}, "
+        self.data += f"{signal['direction']}, {signal['action']})\n"
+
+        # String-valued subfields emitted as KAPI_SIGNAL_* macros.
+        if signal.get('condition'):
+            self.data += f"\t\tKAPI_SIGNAL_CONDITION({self._format_macro_param(signal['condition'])})\n"
+        if signal.get('desc'):
+            self.data += f"\t\tKAPI_SIGNAL_DESC({self._format_macro_param(signal['desc'])})\n"
+        if signal.get('error'):
+            # KAPI_SIGNAL_ERROR expects a numeric/token expression
+            # (e.g. -EINTR), not a quoted string.
+            self.data += f"\t\tKAPI_SIGNAL_ERROR({signal['error']})\n"
+
+        # Enum-valued subfields emitted as unquoted tokens.
+        if signal.get('timing'):
+            self.data += f"\t\tKAPI_SIGNAL_TIMING({signal['timing']})\n"
+        if signal.get('priority'):
+            self.data += f"\t\tKAPI_SIGNAL_PRIORITY({signal['priority']})\n"
+
+        # Boolean flag subfields.
+        if signal.get('restartable'):
+            self.data += "\t\tKAPI_SIGNAL_RESTARTABLE\n"
+        if signal.get('interruptible'):
+            self.data += "\t\tKAPI_SIGNAL_INTERRUPTIBLE\n"
+
+        # Additional struct fields, emitted only when present in the
+        # kerneldoc.
+        if signal.get('target'):
+            self.data += f"\t\tKAPI_SIGNAL_TARGET({self._format_macro_param(signal['target'])})\n"
+        if signal.get('queue'):
+            self.data += f"\t\tKAPI_SIGNAL_QUEUE({self._format_macro_param(signal['queue'])})\n"
+        if signal.get('transform'):
+            # Numeric/token expression (e.g. SIGKILL), not a quoted string.
+            self.data += f"\t\tKAPI_SIGNAL_TRANSFORM({signal['transform']})\n"
+        if signal.get('sa_flags_required'):
+            self.data += f"\t\tKAPI_SIGNAL_SA_FLAGS_REQ({signal['sa_flags_required']})\n"
+        if signal.get('sa_flags_forbidden'):
+            self.data += f"\t\tKAPI_SIGNAL_SA_FLAGS_FORBID({signal['sa_flags_forbidden']})\n"
+        if signal.get('state_required'):
+            self.data += f"\t\tKAPI_SIGNAL_STATE_REQ({signal['state_required']})\n"
+        if signal.get('state_forbidden'):
+            self.data += f"\t\tKAPI_SIGNAL_STATE_FORBID({signal['state_forbidden']})\n"
+
+        self.data += "\t},\n"
+
+    def _process_locks(self, sections):
+        """Process lock specifications"""
+        locks = self._parse_indented_items(
+            self._get_raw_section(sections, 'lock'),
+            self._parse_lock_item
+        )
+
+        if locks:
+            self.data += f"\n\tKAPI_LOCK_COUNT({len(locks)})\n"
+
+            for idx, lock in enumerate(locks):
+                self.data += f"\n\tKAPI_LOCK({idx}, {self._format_macro_param(lock['name'])}, {lock['type']})\n"
+
+                # `.scope` is zero-initialised to KAPI_LOCK_INTERNAL
+                # (acquired-and-released). Emit KAPI_LOCK_ACQUIRED /
+                # KAPI_LOCK_RELEASED only when exactly one of the flags
+                # is true; emitting both would double-initialise `.scope`
+                # which breaks `-Werror=override-init` at W=1.
+                acquired = bool(lock.get('acquired'))
+                released = bool(lock.get('released'))
+                if acquired and not released:
+                    self.data += "\t\tKAPI_LOCK_ACQUIRED\n"
+                elif released and not acquired:
+                    self.data += "\t\tKAPI_LOCK_RELEASED\n"
+
+                if lock.get('desc'):
+                    self.data += f"\t\tKAPI_LOCK_DESC({self._format_macro_param(lock['desc'])})\n"
+
+                self.data += "\t},\n"
+
+    def _process_constraints(self, sections):
+        """Process constraint specifications"""
+        constraints = self._parse_indented_items(
+            self._get_raw_section(sections, 'constraint'),
+            self._parse_constraint_item
+        )
+
+        if constraints:
+            self.data += f"\n\tKAPI_CONSTRAINT_COUNT({len(constraints)})\n"
+
+            for idx, constraint in enumerate(constraints):
+                self.data += f"\n\tKAPI_CONSTRAINT({idx}, {self._format_macro_param(constraint['name'])},\n"
+                self.data += f"\t\t\t{self._format_macro_param(constraint['desc'])})\n"
+
+                if constraint.get('expr'):
+                    self.data += f"\t\tKAPI_CONSTRAINT_EXPR({self._format_macro_param(constraint['expr'])})\n"
+
+                self.data += "\t},\n"
+
+    def _process_side_effects(self, sections):
+        """Process side effect specifications"""
+        effects = self._parse_indented_items(
+            self._get_raw_section(sections, 'side-effect'),
+            self._parse_side_effect_item
+        )
+
+        if effects:
+            self.data += f"\n\tKAPI_SIDE_EFFECT_COUNT({len(effects)})\n"
+
+            for idx, effect in enumerate(effects):
+                # Canonicalise aliases (alloc_memory, modify_state, ...)
+                # to KAPI_EFFECT_*. Accepts '|' or ',' as separators.
+                effect_type = _canon_bitmask_expr(effect['type'], _EFFECT_ALIASES)
+                effect_type = self._validate_effect_type(effect_type)
+
+                self.data += f"\n\tKAPI_SIDE_EFFECT({idx}, {effect_type},\n"
+                self.data += f"\t\t\t {self._format_macro_param(effect['target'])},\n"
+                self.data += f"\t\t\t {self._format_macro_param(effect['desc'])})\n"
+
+                if effect.get('condition'):
+                    self.data += f"\t\tKAPI_EFFECT_CONDITION({self._format_macro_param(effect['condition'])})\n"
+
+                if effect.get('reversible'):
+                    self.data += "\t\tKAPI_EFFECT_REVERSIBLE\n"
+
+                self.data += "\t},\n"
+
+    def _process_state_transitions(self, sections):
+        """Process state transition specifications"""
+        transitions = self._parse_indented_items(
+            self._get_raw_section(sections, 'state-trans'),
+            self._parse_state_trans_item
+        )
+
+        if transitions:
+            self.data += f"\n\tKAPI_STATE_TRANS_COUNT({len(transitions)})\n"
+
+            for idx, trans in enumerate(transitions):
+                self.data += f"\n\tKAPI_STATE_TRANS({idx}, {self._format_macro_param(trans['target'])}, "
+                self.data += f"{self._format_macro_param(trans['from'])}, {self._format_macro_param(trans['to'])},\n"
+                self.data += f"\t\t\t {self._format_macro_param(trans['desc'])})\n"
+
+                if trans.get('condition'):
+                    self.data += f"\t\tKAPI_STATE_TRANS_COND({self._format_macro_param(trans['condition'])})\n"
+
+                self.data += "\t},\n"
+
+    def _process_capabilities(self, sections):
+        """Process capability specifications"""
+        cap_section = self._get_raw_section(sections, 'capability')
+        if not cap_section:
+            return
+
+        lines = cap_section.strip().split('\n')
+        capabilities = []
+        i = 0
+
+        while i < len(lines):
+            line = lines[i].strip()
+            # Skip empty lines and subfield lines (they'll be parsed with their parent)
+            if not line or line.startswith(('allows:', 'without:', 'condition:', 'priority:', 'type:', 'desc:')):
+                i += 1
+                continue
+
+            cap_info = {'line': line}
+
+            # Parse subfields
+            subfields, next_i = self._parse_subfields(
+                lines, i, _CAPABILITY_SUBFIELDS)
+            cap_info.update(subfields)
+            capabilities.append(cap_info)
+            i = next_i
+
+        if capabilities:
+            # Filter out "none" capabilities (no capability required)
+            valid_caps = [cap for cap in capabilities if cap['line'].strip().lower() != 'none']
+
+            if not valid_caps:
+                return
+
+            self.data += f"\n\tKAPI_CAPABILITY_COUNT({len(valid_caps)})\n"
+
+            for idx, cap in enumerate(valid_caps):
+                line = cap['line']
+                parts = line.split(',', 2)
+
+                # Two forms are accepted:
+                # 1. "CAP_NAME" with type/desc as subfields
+                # 2. "CAP_NAME, TYPE, description"
+                if len(parts) >= 2:
+                    # Comma-separated form
+                    cap_name = parts[0].strip()
+                    cap_type = parts[1].strip()
+                    cap_desc = parts[2].strip() if len(parts) > 2 else cap.get('desc', cap_name)
+                else:
+                    # Subfield form: capability name on main line
+                    cap_name = line.strip()
+                    cap_type = cap.get('type', 'KAPI_CAP_PERFORM_OPERATION')
+                    cap_desc = cap.get('desc', cap_name)
+
+                # Map capability type aliases to KAPI_CAP_* enum values.
+                cap_type_map = {
+                    'required':             'KAPI_CAP_PERFORM_OPERATION',
+                    'bypass':               'KAPI_CAP_BYPASS_CHECK',
+                    'grant':                'KAPI_CAP_GRANT_PERMISSION',
+                    'override':             'KAPI_CAP_OVERRIDE_RESTRICTION',
+                    'access':               'KAPI_CAP_ACCESS_RESOURCE',
+                    'modify':               'KAPI_CAP_MODIFY_BEHAVIOR',
+                    'limit':                'KAPI_CAP_INCREASE_LIMIT',
+                    'bypass_check':         'KAPI_CAP_BYPASS_CHECK',
+                    'increase_limit':       'KAPI_CAP_INCREASE_LIMIT',
+                    'override_restriction': 'KAPI_CAP_OVERRIDE_RESTRICTION',
+                    'grant_permission':     'KAPI_CAP_GRANT_PERMISSION',
+                    'modify_behavior':      'KAPI_CAP_MODIFY_BEHAVIOR',
+                    'access_resource':      'KAPI_CAP_ACCESS_RESOURCE',
+                    'perform_operation':    'KAPI_CAP_PERFORM_OPERATION',
+                }
+                cap_type = cap_type_map.get(cap_type, cap_type)
+
+                # Any BYPASS variant maps to KAPI_CAP_BYPASS_CHECK
+                if 'BYPASS' in cap_type and cap_type != 'KAPI_CAP_BYPASS_CHECK':
+                    cap_type = 'KAPI_CAP_BYPASS_CHECK'
+
+                # Ensure cap_type is a valid enum
+                valid_types = [
+                    'KAPI_CAP_BYPASS_CHECK', 'KAPI_CAP_INCREASE_LIMIT',
+                    'KAPI_CAP_OVERRIDE_RESTRICTION', 'KAPI_CAP_GRANT_PERMISSION',
+                    'KAPI_CAP_MODIFY_BEHAVIOR', 'KAPI_CAP_ACCESS_RESOURCE',
+                    'KAPI_CAP_PERFORM_OPERATION'
+                ]
+                if cap_type not in valid_types:
+                    cap_type = 'KAPI_CAP_PERFORM_OPERATION'
+
+                self.data += f"\n\tKAPI_CAPABILITY({idx}, {cap_name}, {self._format_macro_param(cap_desc)}, {cap_type})\n"
+
+                for key, macro in [
+                    ('allows', 'KAPI_CAP_ALLOWS'),
+                    ('without', 'KAPI_CAP_WITHOUT'),
+                    ('condition', 'KAPI_CAP_CONDITION'),
+                    ('priority', 'KAPI_CAP_PRIORITY'),
+                ]:
+                    if cap.get(key):
+                        value = self._format_macro_param(cap[key]) if key != 'priority' else cap[key]
+                        self.data += f"\t\t{macro}({value})\n"
+
+                self.data += "\t},\n"
+
+    # Skip output methods for non-function types
+    def out_enum(self, fname, name, args): pass
+    def out_typedef(self, fname, name, args): pass
+    def out_struct(self, fname, name, args): pass
+    def out_doc(self, fname, name, args): pass
diff --git a/tools/lib/python/kdoc/kdoc_files.py b/tools/lib/python/kdoc/kdoc_files.py
index ed82b6e6ab25b..5b81a1e4cc9e9 100644
--- a/tools/lib/python/kdoc/kdoc_files.py
+++ b/tools/lib/python/kdoc/kdoc_files.py
@@ -94,13 +94,14 @@ class KdocConfig():
     """
     def __init__(self, verbose=False, werror=False, wreturn=False,
                  wshort_desc=False, wcontents_before_sections=False,
-                 logger=None):
+                 logger=None, apispec=False):
 
         self.verbose = verbose
         self.werror = werror
         self.wreturn = wreturn
         self.wshort_desc =  wshort_desc
         self.wcontents_before_sections = wcontents_before_sections
+        self.apispec = apispec
 
         if logger:
             self.log = logger
@@ -159,6 +160,10 @@ class KernelFiles():
     ``yaml_content``
         Defines what will be inside the YAML file.
 
+    ``apispec``
+        If True, also parse the kernel API specification sections used
+        by the ``-apispec`` output. Default: False.
+
     Note:
         There are two type of parsers defined here:
 
@@ -232,7 +237,8 @@ class KernelFiles():
     def __init__(self, verbose=False, out_style=None, xforms=None,
                  werror=False, wreturn=False, wshort_desc=False,
                  wcontents_before_sections=False,
-                 yaml_file=None, yaml_content=None, logger=None):
+                 yaml_file=None, yaml_content=None, logger=None,
+                 apispec=False):
         """
         Initialize startup variables and parse all files.
         """
@@ -271,7 +277,7 @@ class KernelFiles():
         # used to send control configuration to KernelDoc class. As such,
         # those variables are read-only inside the KernelDoc.
         self.config = KdocConfig(verbose, werror, wreturn, wshort_desc,
-                                 wcontents_before_sections, logger)
+                                 wcontents_before_sections, logger, apispec)
 
         # Override log warning, as we want to count errors
         self.config.warning = self.warning
diff --git a/tools/lib/python/kdoc/kdoc_parser.py b/tools/lib/python/kdoc/kdoc_parser.py
index d9ad1ddc87dd1..a29fdccda8aee 100644
--- a/tools/lib/python/kdoc/kdoc_parser.py
+++ b/tools/lib/python/kdoc/kdoc_parser.py
@@ -30,6 +30,23 @@ from kdoc.kdoc_item import KdocItem
 # Allow whitespace at end of comment start.
 doc_start = KernRe(r'^/\*\*\s*$', cache=False)
 
+# Sections that are allowed to be duplicated for API specifications
+# These represent lists of items (multiple errors, signals, etc.)
+ALLOWED_DUPLICATE_SECTIONS = {
+    'param', '@param',
+    'error', '@error',
+    'signal', '@signal',
+    'lock', '@lock',
+    'side-effect', '@side-effect',
+    'state-trans', '@state-trans',
+    'capability', '@capability',
+    'constraint', '@constraint',
+    'validation-group', '@validation-group',
+    'validation-rule', '@validation-rule',
+    'validation-flag', '@validation-flag',
+    'struct-field', '@struct-field',
+}
+
 doc_end = KernRe(r'\*/', cache=False)
 doc_com = KernRe(r'\s*\*\s*', cache=False)
 doc_com_body = KernRe(r'\s*\* ?', cache=False)
@@ -48,6 +65,71 @@ doc_sect = doc_com + \
     KernRe(r'\s*(@[.\w]+|@\.\.\.|' + known_section_names + r')\s*:([^:].*)?$',
            flags=re.I, cache=False)
 
+# API specification section names (for KAPI spec framework), only
+# recognized when generating -apispec output
+# Format: (base_name, has_count_variant, has_other_variants)
+# Sections with has_count_variant=True need negative lookahead in kapi_doc_sect
+# to avoid matching 'error' when 'error-count' is intended
+_kapi_base_sections = [
+    # (name, needs_lookahead, additional_variants)
+    ('api-type', False, []),
+    ('api-version', False, []),
+    ('param', True, []),  # has param-count
+    ('struct', True, ['struct-type', 'struct-field', 'struct-field-[a-z\\-]+']),
+    ('validation-group', False, []),
+    ('validation-policy', False, []),
+    ('validation-flag', False, []),
+    ('validation-rule', False, []),
+    ('error', True, ['error-code', 'error-condition']),
+    ('capability', True, []),
+    ('signal', True, []),
+    ('lock', True, []),
+    ('context-flags', False, []),
+    ('contexts', False, []),
+    ('return', True, ['return-type', 'return-check', 'return-check-type',
+                      'return-success', 'return-desc']),
+    ('long-desc', False, []),
+    ('constraint', True, []),
+    ('side-effect', True, []),
+    ('state-trans', True, []),
+]
+
+def _build_kapi_patterns():
+    """Build KAPI section patterns from the base definitions."""
+    validation_parts = []  # For kapi_known_sections (simple validation)
+    parsing_parts = []     # For kapi_doc_sect (with negative lookaheads)
+
+    for name, has_count, variants in _kapi_base_sections:
+        # Add base name (with optional @ prefix)
+        validation_parts.append(f'@?{name}')
+        if has_count:
+            # Need negative lookahead to not match 'name-count' or 'name-*'
+            parsing_parts.append(f'@?{name}(?!-)')
+            validation_parts.append(f'@?{name}-count')
+            parsing_parts.append(f'@?{name}-count')
+        else:
+            parsing_parts.append(f'@?{name}')
+
+        # Add variants
+        for variant in variants:
+            validation_parts.append(f'@?{variant}')
+            parsing_parts.append(f'@?{variant}')
+
+    # Add catch-all for kapi-* extensions
+    validation_parts.append(r'@?kapi-.*')
+    parsing_parts.append(r'@?kapi-.*')
+
+    return '|'.join(validation_parts), '|'.join(parsing_parts)
+
+_kapi_validation_pattern, _kapi_parsing_pattern = _build_kapi_patterns()
+
+kapi_known_sections = KernRe(known_section_names + '|' +
+                             _kapi_validation_pattern, flags=re.I)
+kapi_doc_sect = doc_com + \
+    KernRe(r'\s*(@[.\w\-]+|@\.\.\.|' + known_section_names + '|' +
+           _kapi_parsing_pattern + r')\s*:([^:].*)?$',
+           flags=re.I, cache=False)
+
 doc_content = doc_com_body + KernRe(r'(.*)', cache=False)
 doc_inline_start = KernRe(r'^\s*/\*\*\s*$', cache=False)
 doc_inline_sect = KernRe(r'\s*\*\s*(@\s*[\w][\w\.]*\s*):(.*)', cache=False)
@@ -214,7 +296,9 @@ class KernelEntry:
         else:
             if name in self.sections and self.sections[name] != "":
                 # Only warn on user-specified duplicate section names
-                if name != SECTION_DEFAULT:
+                # Skip warning for sections that are expected to have duplicates
+                # (like error, param, signal, etc. for API specifications)
+                if name != SECTION_DEFAULT and name not in ALLOWED_DUPLICATE_SECTIONS:
                     self.emit_msg(self.new_start_line,
                                   f"duplicate section name '{name}'")
                 # Treat as a new paragraph - add a blank line
@@ -255,6 +339,13 @@ class KernelDoc:
         self.xforms = xforms
         self.store_src = store_src
 
+        if self.config.apispec:
+            self.known_sections = kapi_known_sections
+            self.doc_sect = kapi_doc_sect
+        else:
+            self.known_sections = known_sections
+            self.doc_sect = doc_sect
+
         tokenizer_set_log(self.config.log, f"{self.fname}: CMatch: ")
 
         # Initial state for the state machines
@@ -611,7 +702,7 @@ class KernelDoc:
         """
         for section in self.entry.sections:
             if section not in self.entry.parameterlist and \
-               not known_sections.search(section):
+               not self.known_sections.search(section):
                 hint = self.get_suggestions_hint(section, self.entry.parameterlist)
                 if decl_type == 'function':
                     dname = f"{decl_type} parameter"
@@ -1314,12 +1405,12 @@ class KernelDoc:
         """
         Helper function to determine if a new section is being started.
         """
-        if doc_sect.search(line):
+        if self.doc_sect.search(line):
             self.state = state.BODY
             #
             # Pick out the name of our new section, tweaking it if need be.
             #
-            newsection = doc_sect.group(1)
+            newsection = self.doc_sect.group(1)
             if newsection.lower() == 'description':
                 newsection = 'Description'
             elif newsection.lower() == 'context':
@@ -1334,7 +1425,7 @@ class KernelDoc:
             #
             # Initialize the contents, and get the new section going.
             #
-            newcontents = doc_sect.group(2)
+            newcontents = self.doc_sect.group(2)
             if not newcontents:
                 newcontents = ""
             self.dump_section()
-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* [PATCH v5 03/11] kernel/api: add debugfs interface for kernel API specifications
  2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 01/11] kernel/api: introduce kernel API specification framework Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 02/11] kernel/api: enable kerneldoc-based API specifications Sasha Levin
@ 2026-10-08  8:49 ` Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 04/11] tools/kapi: add kernel API specification extraction tool Sasha Levin
                   ` (7 subsequent siblings)
  10 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

Add a debugfs interface, enabled by CONFIG_KAPI_SPEC_DEBUGFS, to expose
kernel API specifications at runtime. This allows tools and users to
query the complete API specifications through the debugfs filesystem.

The interface provides:
- /sys/kernel/debug/kapi/list - lists all available API specifications
- /sys/kernel/debug/kapi/specs/<name> - detailed info for each API
- /sys/kernel/debug/kapi/specs-json/<name> - the same spec as JSON

Each specification file includes:
- Function name, version, and descriptions
- Execution context requirements and flags
- Parameter details with types, flags, and constraints
- Return value specifications and success conditions
- Error codes with descriptions and conditions
- Locking requirements and constraints
- Signal handling specifications
- Examples and notes

This enables runtime introspection of kernel APIs for documentation
tools, static analyzers, and debugging purposes.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 Documentation/dev-tools/kernel-api-spec.rst |  82 ++-
 kernel/api/Kconfig                          |  20 +
 kernel/api/Makefile                         |   3 +
 kernel/api/kapi_debugfs.c                   | 553 ++++++++++++++++++++
 4 files changed, 641 insertions(+), 17 deletions(-)
 create mode 100644 kernel/api/kapi_debugfs.c

diff --git a/Documentation/dev-tools/kernel-api-spec.rst b/Documentation/dev-tools/kernel-api-spec.rst
index 03b32d3718c8e..fa8aabb92a503 100644
--- a/Documentation/dev-tools/kernel-api-spec.rst
+++ b/Documentation/dev-tools/kernel-api-spec.rst
@@ -262,6 +262,7 @@ Runtime validation is controlled by kernel configuration:
 
 1. Enable ``CONFIG_KAPI_SPEC`` to build the framework
 2. Enable ``CONFIG_KAPI_RUNTIME_CHECKS`` for runtime validation
+3. Optionally enable ``CONFIG_KAPI_SPEC_DEBUGFS`` for debugfs interface
 
 Validation Behavior
 -------------------
@@ -310,6 +311,68 @@ specification. Kerneldoc annotations cannot set it, so it is only available to a
     .constraint_type = KAPI_CONSTRAINT_CUSTOM,
     .validate = validate_buffer_size,
 
+DebugFS Interface
+=================
+
+The debugfs interface provides runtime access to API specifications:
+
+Directory Structure
+-------------------
+
+::
+
+    /sys/kernel/debug/kapi/
+    ├── list                     # Overview of all registered API specs
+    ├── specs/                   # Per-API specification files
+    │   ├── sys_open             # Human-readable spec for sys_open
+    │   ├── sys_close            # Human-readable spec for sys_close
+    │   ├── sys_read             # Human-readable spec for sys_read
+    │   ├── sys_write            # Human-readable spec for sys_write
+    │   └── sys_madvise          # Human-readable spec for sys_madvise
+    └── specs-json/              # Machine-readable counterpart of specs/
+        ├── sys_open             # JSON spec for sys_open
+        └── ...
+
+Usage Examples
+--------------
+
+List all available API specifications::
+
+    $ cat /sys/kernel/debug/kapi/list
+    Available Kernel API Specifications
+    ===================================
+
+    sys_open - Open or create a file
+    sys_close - Close a file descriptor
+    sys_read - Read data from a file descriptor
+    sys_write - Write data to a file descriptor
+    sys_madvise - Give advice about use of memory
+
+    Total: 5 specifications
+
+Query specific API::
+
+    $ cat /sys/kernel/debug/kapi/specs/sys_open
+    Kernel API Specification
+    ========================
+
+    Name: sys_open
+    Version: 1
+    Description: Open or create a file
+    ...
+
+The ``specs-json/`` files carry the complete specification: for each
+parameter the type class, flags and the constraint (type, range, valid
+mask, enumerated values, alignment, size and the index of the parameter
+holding a buffer's size), the return check, errors, locks, signals,
+signal masks, side effects, state transitions, capabilities, additional
+constraints and structure specifications. Masks and flag words are
+hex strings, enumerations are lower-case tokens such as
+``"constraint_type": "buffer"``, and ``size_param_idx`` is a 0-based
+index into ``parameters`` (``null`` when unused). The ``kapi`` tool
+reads these files with ``--debugfs`` and reports the same data as
+``--vmlinux``.
+
 Performance Considerations
 ==========================
 
@@ -620,20 +683,5 @@ Submitting Specifications
 1. Add specifications to the same file as the API implementation
 2. Follow existing patterns and naming conventions
 3. Test with CONFIG_KAPI_RUNTIME_CHECKS enabled
-4. Run scripts/checkpatch.pl on your changes
-
-Review Criteria
----------------
-
-Specifications will be reviewed for:
-
-1. **Completeness**: All parameters and errors documented
-2. **Accuracy**: Specification matches implementation
-3. **Clarity**: Descriptions are clear and helpful
-4. **Consistency**: Follows framework conventions
-5. **Performance**: No unnecessary runtime overhead
-
-Contact
--------
-
-- Maintainer: Sasha Levin <sashal@kernel.org>
+4. Verify debugfs output is correct
+5. Run scripts/checkpatch.pl on your changes
diff --git a/kernel/api/Kconfig b/kernel/api/Kconfig
index 1cd55b252f0d5..6075ab82bd231 100644
--- a/kernel/api/Kconfig
+++ b/kernel/api/Kconfig
@@ -41,6 +41,26 @@ config KAPI_RUNTIME_CHECKS
 
 	  If unsure, say N.
 
+config KAPI_SPEC_DEBUGFS
+	bool "Export kernel API specifications via debugfs"
+	depends on KAPI_SPEC
+	depends on DEBUG_FS
+	help
+	  This option enables exporting kernel API specifications through
+	  the debugfs filesystem. When enabled, specifications can be
+	  accessed at /sys/kernel/debug/kapi/.
+
+	  The debugfs interface provides:
+	  - A list of all available API specifications
+	  - Detailed information for each API including parameters,
+	    return values, errors, locking requirements, and constraints
+	  - Complete machine-readable representation of the specs
+
+	  This is useful for documentation tools, static analyzers, and
+	  runtime introspection of kernel APIs.
+
+	  If unsure, say N.
+
 config KAPI_KUNIT_TEST
 	tristate "KUnit tests for KAPI framework" if !KUNIT_ALL_TESTS
 	depends on KAPI_SPEC
diff --git a/kernel/api/Makefile b/kernel/api/Makefile
index 6e14ca243980c..2e1c3555d5aa5 100644
--- a/kernel/api/Makefile
+++ b/kernel/api/Makefile
@@ -6,5 +6,8 @@
 # Core API specification framework
 obj-y				+= kernel_api_spec.o
 
+# Debugfs interface for kernel API specs
+obj-$(CONFIG_KAPI_SPEC_DEBUGFS) += kapi_debugfs.o
+
 # KUnit tests
 obj-$(CONFIG_KAPI_KUNIT_TEST) += kapi_kunit.o
diff --git a/kernel/api/kapi_debugfs.c b/kernel/api/kapi_debugfs.c
new file mode 100644
index 0000000000000..5d9c2649a0dc6
--- /dev/null
+++ b/kernel/api/kapi_debugfs.c
@@ -0,0 +1,553 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+ *
+ * Kernel API specification debugfs interface
+ *
+ * This provides a debugfs interface to expose kernel API specifications
+ * at runtime, allowing tools and users to query the complete API specs.
+ */
+
+#include <linux/debugfs.h>
+#include <linux/kernel.h>
+#include <linux/init.h>
+#include <linux/seq_file.h>
+#include <linux/kernel_api_spec.h>
+#include <linux/slab.h>
+#include <linux/string.h>
+
+#include "internal.h"
+
+/* Helper to print parameter flags */
+static void print_param_flags(struct seq_file *m, u32 flags)
+{
+	seq_puts(m, "    flags: ");
+	if ((flags & KAPI_PARAM_INOUT) == KAPI_PARAM_INOUT)
+		seq_puts(m, "INOUT ");
+	else if (flags & KAPI_PARAM_IN)
+		seq_puts(m, "IN ");
+	else if (flags & KAPI_PARAM_OUT)
+		seq_puts(m, "OUT ");
+	if (flags & KAPI_PARAM_OPTIONAL)
+		seq_puts(m, "OPTIONAL ");
+	if (flags & KAPI_PARAM_CONST)
+		seq_puts(m, "CONST ");
+	if (flags & KAPI_PARAM_USER)
+		seq_puts(m, "USER ");
+	if (flags & KAPI_PARAM_VOLATILE)
+		seq_puts(m, "VOLATILE ");
+	if (flags & KAPI_PARAM_DMA)
+		seq_puts(m, "DMA ");
+	if (flags & KAPI_PARAM_ALIGNED)
+		seq_puts(m, "ALIGNED ");
+	seq_puts(m, "\n");
+}
+
+/* Helper to print context flags */
+static void print_context_flags(struct seq_file *m, u32 flags)
+{
+	seq_puts(m, "Context flags: ");
+	if (flags & KAPI_CTX_PROCESS)
+		seq_puts(m, "PROCESS ");
+	if (flags & KAPI_CTX_HARDIRQ)
+		seq_puts(m, "HARDIRQ ");
+	if (flags & KAPI_CTX_SOFTIRQ)
+		seq_puts(m, "SOFTIRQ ");
+	if (flags & KAPI_CTX_NMI)
+		seq_puts(m, "NMI ");
+	if (flags & KAPI_CTX_SLEEPABLE)
+		seq_puts(m, "SLEEPABLE ");
+	if (flags & KAPI_CTX_ATOMIC)
+		seq_puts(m, "ATOMIC ");
+	if (flags & KAPI_CTX_PREEMPT_DISABLED)
+		seq_puts(m, "PREEMPT_DISABLED ");
+	if (flags & KAPI_CTX_IRQ_DISABLED)
+		seq_puts(m, "IRQ_DISABLED ");
+	seq_puts(m, "\n");
+}
+
+/*
+ * Print a multi-line value with every line indented, so that no line of it
+ * can be mistaken for a field of its own.
+ */
+static void print_block(struct seq_file *m, const char *text)
+{
+	while (*text) {
+		size_t len = strcspn(text, "\n");
+
+		if (len)
+			seq_printf(m, "  %.*s", (int)len, text);
+		seq_putc(m, '\n');
+		text += len;
+		if (*text == '\n')
+			text++;
+	}
+}
+
+/* Helper to print a list of signed values */
+static void print_s64_list(struct seq_file *m, const char *label,
+			   const s64 *vals, u32 count)
+{
+	u32 i;
+
+	if (!vals || !count)
+		return;
+
+	seq_printf(m, "%s:", label);
+	for (i = 0; i < count; i++)
+		seq_printf(m, " %lld", vals[i]);
+	seq_puts(m, "\n");
+}
+
+/* Helper to print which parameter carries a buffer's element count */
+static void print_size_param(struct seq_file *m,
+			     const struct kernel_api_spec *spec,
+			     const struct kapi_param_spec *param)
+{
+	int idx = param->size_param_idx - 1;
+
+	if (idx < 0)
+		return;
+
+	seq_printf(m, "      size_param: %s (index %d)\n",
+		   idx < spec->param_count && idx < KAPI_MAX_PARAMS ?
+		   spec->params[idx].name : "?", idx);
+	if (param->size_multiplier)
+		seq_printf(m, "      size_multiplier: %zu\n",
+			   param->size_multiplier);
+}
+
+/* Show function for individual API spec */
+static int kapi_spec_show(struct seq_file *m, void *v)
+{
+	struct kernel_api_spec *spec = m->private;
+	int i;
+
+	seq_puts(m, "Kernel API Specification\n");
+	seq_puts(m, "========================\n\n");
+
+	/* Basic info */
+	seq_printf(m, "Name: %s\n", spec->name);
+	seq_printf(m, "Version: %u\n", spec->version);
+	seq_printf(m, "Description: %s\n", spec->description);
+	if (spec->long_description && *spec->long_description) {
+		seq_puts(m, "Long description:\n");
+		print_block(m, spec->long_description);
+	}
+
+	/* Context */
+	print_context_flags(m, spec->context_flags);
+	seq_puts(m, "\n");
+
+	/* Parameters */
+	if (spec->param_count > 0) {
+		seq_printf(m, "Parameters (%u):\n", spec->param_count);
+		for (i = 0; i < spec->param_count && i < KAPI_MAX_PARAMS; i++) {
+			struct kapi_param_spec *param = &spec->params[i];
+
+			seq_printf(m, "  [%d] %s:\n", i, param->name);
+			seq_printf(m, "    type: %s (%s)\n",
+				   kapi_param_type_to_string(param->type), param->type_name);
+			print_param_flags(m, param->flags);
+			if (param->description && *param->description)
+				seq_printf(m, "    description: %s\n", param->description);
+			if (param->size > 0)
+				seq_printf(m, "    size: %zu\n", param->size);
+			if (param->alignment > 0)
+				seq_printf(m, "    alignment: %zu\n", param->alignment);
+
+			/* Print constraints if any */
+			if (param->constraint_type != KAPI_CONSTRAINT_NONE ||
+			    (param->constraints && *param->constraints)) {
+				seq_puts(m, "    constraints:\n");
+				switch (param->constraint_type) {
+				case KAPI_CONSTRAINT_NONE:
+					break;
+				case KAPI_CONSTRAINT_RANGE:
+					seq_puts(m, "      type: range\n");
+					seq_printf(m, "      min: %lld\n", param->min_value);
+					seq_printf(m, "      max: %lld\n", param->max_value);
+					break;
+				case KAPI_CONSTRAINT_MASK:
+					seq_puts(m, "      type: mask\n");
+					seq_printf(m, "      valid_bits: 0x%llx\n",
+						   param->valid_mask);
+					break;
+				case KAPI_CONSTRAINT_ENUM:
+					seq_puts(m, "      type: enum\n");
+					seq_printf(m, "      count: %u\n", param->enum_count);
+					print_s64_list(m, "      values",
+						       param->enum_values,
+						       param->enum_count);
+					break;
+				case KAPI_CONSTRAINT_USER_STRING:
+					seq_puts(m, "      type: user_string\n");
+					seq_printf(m, "      min_len: %lld\n", param->min_value);
+					seq_printf(m, "      max_len: %lld\n", param->max_value);
+					break;
+				case KAPI_CONSTRAINT_USER_PATH:
+					seq_puts(m, "      type: user_path\n");
+					seq_puts(m, "      max_len: PATH_MAX (4096)\n");
+					break;
+				case KAPI_CONSTRAINT_USER_PTR:
+					seq_puts(m, "      type: user_ptr\n");
+					seq_printf(m, "      size: %zu bytes\n", param->size);
+					print_size_param(m, spec, param);
+					break;
+				case KAPI_CONSTRAINT_BUFFER:
+					seq_puts(m, "      type: buffer\n");
+					print_size_param(m, spec, param);
+					break;
+				case KAPI_CONSTRAINT_ALIGNMENT:
+					seq_puts(m, "      type: alignment\n");
+					seq_printf(m, "      alignment: %zu\n", param->alignment);
+					break;
+				case KAPI_CONSTRAINT_POWER_OF_TWO:
+					seq_puts(m, "      type: power_of_two\n");
+					break;
+				case KAPI_CONSTRAINT_PAGE_ALIGNED:
+					seq_puts(m, "      type: page_aligned\n");
+					break;
+				case KAPI_CONSTRAINT_NONZERO:
+					seq_puts(m, "      type: nonzero\n");
+					break;
+				case KAPI_CONSTRAINT_CUSTOM:
+					seq_puts(m, "      type: custom\n");
+					break;
+				default:
+					seq_printf(m, "      type: unknown (%d)\n",
+						   param->constraint_type);
+					break;
+				}
+				if (param->constraints && *param->constraints)
+					seq_printf(m, "      description: %s\n",
+						   param->constraints);
+			}
+			seq_puts(m, "\n");
+		}
+	}
+
+	/* Return value */
+	seq_puts(m, "Return value:\n");
+	seq_printf(m, "  type: %s\n", spec->return_spec.type_name);
+	if (spec->return_spec.description && *spec->return_spec.description)
+		seq_printf(m, "  description: %s\n", spec->return_spec.description);
+
+	switch (spec->return_spec.check_type) {
+	case KAPI_RETURN_EXACT:
+		seq_printf(m, "  success: == %lld\n", spec->return_spec.success_value);
+		break;
+	case KAPI_RETURN_RANGE:
+		seq_printf(m, "  success: [%lld, %lld]\n",
+			   spec->return_spec.success_min,
+			   spec->return_spec.success_max);
+		break;
+	case KAPI_RETURN_FD:
+		seq_puts(m, "  success: valid file descriptor (>= 0)\n");
+		break;
+	case KAPI_RETURN_ERROR_CHECK:
+		seq_puts(m, "  success: error check\n");
+		print_s64_list(m, "  error values",
+			       spec->return_spec.error_values,
+			       spec->return_spec.error_count);
+		break;
+	case KAPI_RETURN_CUSTOM:
+		seq_puts(m, "  success: custom check\n");
+		break;
+	case KAPI_RETURN_NO_RETURN:
+		seq_puts(m, "  success: does not return\n");
+		break;
+	default:
+		break;
+	}
+	seq_puts(m, "\n");
+
+	/* Errors */
+	if (spec->error_count > 0) {
+		seq_printf(m, "Errors (%u):\n", spec->error_count);
+		for (i = 0; i < spec->error_count && i < KAPI_MAX_ERRORS; i++) {
+			struct kapi_error_spec *err = &spec->errors[i];
+
+			seq_printf(m, "  %s (%d): %s\n",
+				   err->name, err->error_code, err->description);
+			if (err->condition && *err->condition)
+				seq_printf(m, "    condition: %s\n", err->condition);
+		}
+		seq_puts(m, "\n");
+	}
+
+	/* Locks */
+	if (spec->lock_count > 0) {
+		seq_printf(m, "Locks (%u):\n", spec->lock_count);
+		for (i = 0; i < spec->lock_count && i < KAPI_MAX_LOCKS; i++) {
+			struct kapi_lock_spec *lock = &spec->locks[i];
+
+			seq_printf(m, "  %s (%s): %s\n", lock->lock_name,
+				   kapi_lock_type_to_string(lock->lock_type),
+				   lock->description);
+			seq_printf(m, "    scope: %s\n",
+				   kapi_lock_scope_to_string(lock->scope));
+		}
+		seq_puts(m, "\n");
+	}
+
+	/* Constraints */
+	if (spec->constraint_count > 0) {
+		seq_printf(m, "Additional constraints (%u):\n", spec->constraint_count);
+		for (i = 0; i < spec->constraint_count && i < KAPI_MAX_CONSTRAINTS; i++) {
+			struct kapi_constraint_spec *cons = &spec->constraints[i];
+
+			seq_printf(m, "  - %s", cons->name);
+			if (cons->description && *cons->description)
+				seq_printf(m, ": %s", cons->description);
+			seq_puts(m, "\n");
+			if (cons->expression && *cons->expression)
+				seq_printf(m, "    expression: %s\n", cons->expression);
+		}
+		seq_puts(m, "\n");
+	}
+
+	/* Signals */
+	if (spec->signal_count > 0) {
+		seq_printf(m, "Signal handling (%u):\n", spec->signal_count);
+		for (i = 0; i < spec->signal_count && i < KAPI_MAX_SIGNALS; i++) {
+			struct kapi_signal_spec *sig = &spec->signals[i];
+
+			seq_printf(m, "  %s (%d):\n", sig->signal_name, sig->signal_num);
+			seq_puts(m, "    direction: ");
+			if (sig->direction & KAPI_SIGNAL_SEND)
+				seq_puts(m, "send ");
+			if (sig->direction & KAPI_SIGNAL_RECEIVE)
+				seq_puts(m, "receive ");
+			if (sig->direction & KAPI_SIGNAL_HANDLE)
+				seq_puts(m, "handle ");
+			if (sig->direction & KAPI_SIGNAL_BLOCK)
+				seq_puts(m, "block ");
+			if (sig->direction & KAPI_SIGNAL_IGNORE)
+				seq_puts(m, "ignore ");
+			seq_puts(m, "\n");
+			seq_puts(m, "    action: ");
+			switch (sig->action) {
+			case KAPI_SIGNAL_ACTION_DEFAULT:
+				seq_puts(m, "default");
+				break;
+			case KAPI_SIGNAL_ACTION_TERMINATE:
+				seq_puts(m, "terminate");
+				break;
+			case KAPI_SIGNAL_ACTION_COREDUMP:
+				seq_puts(m, "coredump");
+				break;
+			case KAPI_SIGNAL_ACTION_STOP:
+				seq_puts(m, "stop");
+				break;
+			case KAPI_SIGNAL_ACTION_CONTINUE:
+				seq_puts(m, "continue");
+				break;
+			case KAPI_SIGNAL_ACTION_CUSTOM:
+				seq_puts(m, "custom");
+				break;
+			case KAPI_SIGNAL_ACTION_RETURN:
+				seq_puts(m, "return");
+				break;
+			case KAPI_SIGNAL_ACTION_RESTART:
+				seq_puts(m, "restart");
+				break;
+			case KAPI_SIGNAL_ACTION_QUEUE:
+				seq_puts(m, "queue");
+				break;
+			case KAPI_SIGNAL_ACTION_DISCARD:
+				seq_puts(m, "discard");
+				break;
+			case KAPI_SIGNAL_ACTION_TRANSFORM:
+				seq_puts(m, "transform");
+				break;
+			default:
+				seq_puts(m, "unknown");
+				break;
+			}
+			seq_puts(m, "\n");
+			if (sig->description && *sig->description)
+				seq_printf(m, "    description: %s\n", sig->description);
+		}
+		seq_puts(m, "\n");
+	}
+
+	/* Side effects */
+	if (spec->side_effect_count > 0) {
+		seq_printf(m, "Side effects (%u):\n", spec->side_effect_count);
+		for (i = 0; i < spec->side_effect_count && i < KAPI_MAX_SIDE_EFFECTS; i++) {
+			const struct kapi_side_effect *eff = &spec->side_effects[i];
+
+			seq_printf(m, "  - %s", eff->target);
+			if (eff->description && *eff->description)
+				seq_printf(m, ": %s", eff->description);
+			if (eff->reversible)
+				seq_puts(m, " (reversible)");
+			seq_puts(m, "\n");
+		}
+		seq_puts(m, "\n");
+	}
+
+	/* State transitions */
+	if (spec->state_trans_count > 0) {
+		seq_printf(m, "State transitions (%u):\n", spec->state_trans_count);
+		for (i = 0; i < spec->state_trans_count && i < KAPI_MAX_STATE_TRANS; i++) {
+			const struct kapi_state_transition *trans = &spec->state_transitions[i];
+
+			seq_printf(m, "  %s: %s -> %s\n", trans->object,
+				   trans->from_state, trans->to_state);
+			if (trans->description && *trans->description)
+				seq_printf(m, "    %s\n", trans->description);
+		}
+		seq_puts(m, "\n");
+	}
+
+	/* Capabilities */
+	if (spec->capability_count > 0) {
+		seq_printf(m, "Capabilities (%u):\n", spec->capability_count);
+		for (i = 0; i < spec->capability_count && i < KAPI_MAX_CAPABILITIES; i++) {
+			const struct kapi_capability_spec *cap = &spec->capabilities[i];
+
+			seq_printf(m, "  %s (%d):\n", cap->cap_name,
+				   cap->capability);
+			if (cap->allows && *cap->allows)
+				seq_printf(m, "    allows: %s\n", cap->allows);
+			if (cap->without_cap && *cap->without_cap)
+				seq_printf(m, "    without: %s\n", cap->without_cap);
+		}
+		seq_puts(m, "\n");
+	}
+
+	/* Additional info */
+	if (spec->examples && *spec->examples) {
+		seq_puts(m, "Examples:\n");
+		print_block(m, spec->examples);
+		seq_putc(m, '\n');
+	}
+	if (spec->notes && *spec->notes) {
+		seq_puts(m, "Notes:\n");
+		print_block(m, spec->notes);
+		seq_putc(m, '\n');
+	}
+
+	return 0;
+}
+
+static int kapi_spec_open(struct inode *inode, struct file *file)
+{
+	return single_open(file, kapi_spec_show, inode->i_private);
+}
+
+static const struct file_operations kapi_spec_fops = {
+	.open = kapi_spec_open,
+	.read = seq_read,
+	.llseek = seq_lseek,
+	.release = single_release,
+};
+
+/*
+ * JSON view of a single spec.  kapi_export_json() writes straight into the
+ * seq_file buffer; when the output does not fit, flagging an overflow makes
+ * seq_file retry with a larger buffer, so no fixed size limit applies.
+ */
+static int kapi_spec_json_show(struct seq_file *m, void *v)
+{
+	const struct kernel_api_spec *spec = m->private;
+	size_t size;
+	char *buf;
+	int ret;
+
+	if (!spec)
+		return -EINVAL;
+
+	size = seq_get_buf(m, &buf);
+	ret = size ? kapi_export_json(spec, buf, size) : -E2BIG;
+	if (ret == -E2BIG) {
+		seq_commit(m, -1);
+		return 0;
+	}
+	if (ret < 0)
+		return ret;
+
+	seq_commit(m, ret);
+	return 0;
+}
+
+static int kapi_spec_json_open(struct inode *inode, struct file *file)
+{
+	return single_open(file, kapi_spec_json_show, inode->i_private);
+}
+
+static const struct file_operations kapi_spec_json_fops = {
+	.open = kapi_spec_json_open,
+	.read = seq_read,
+	.llseek = seq_lseek,
+	.release = single_release,
+};
+
+/*
+ * Show all available API specs.
+ *
+ * Note: This only iterates the static .kapi_specs section. Specs registered
+ * dynamically via kapi_register_spec() are not included in this listing
+ * or in the per-spec debugfs files.
+ */
+static int kapi_list_show(struct seq_file *m, void *v)
+{
+	const struct kernel_api_spec * const *pp;
+	int count = 0;
+
+	seq_puts(m, "Available Kernel API Specifications\n");
+	seq_puts(m, "===================================\n\n");
+
+	for (pp = __start_kapi_specs; pp < __stop_kapi_specs; pp++) {
+		const struct kernel_api_spec *spec = *pp;
+
+		if (!spec)
+			continue;
+		seq_printf(m, "%s - %s\n", spec->name, spec->description);
+		count++;
+	}
+
+	seq_printf(m, "\nTotal: %d specifications\n", count);
+	return 0;
+}
+
+static int kapi_list_open(struct inode *inode, struct file *file)
+{
+	return single_open(file, kapi_list_show, NULL);
+}
+
+static const struct file_operations kapi_list_fops = {
+	.open = kapi_list_open,
+	.read = seq_read,
+	.llseek = seq_lseek,
+	.release = single_release,
+};
+
+static int __init kapi_debugfs_init(void)
+{
+	const struct kernel_api_spec * const *pp;
+	struct dentry *root, *spec_dir, *json_dir;
+
+	root = debugfs_create_dir("kapi", NULL);
+	debugfs_create_file("list", 0444, root, NULL, &kapi_list_fops);
+	spec_dir = debugfs_create_dir("specs", root);
+	json_dir = debugfs_create_dir("specs-json", root);
+
+	for (pp = __start_kapi_specs; pp < __stop_kapi_specs; pp++) {
+		const struct kernel_api_spec *spec = *pp;
+
+		if (!spec || !spec->name)
+			continue;
+		debugfs_create_file(spec->name, 0444, spec_dir,
+				    (void *)spec, &kapi_spec_fops);
+		debugfs_create_file(spec->name, 0444, json_dir,
+				    (void *)spec, &kapi_spec_json_fops);
+	}
+
+	return 0;
+}
+
+/* Initialize as part of kernel, not as a module */
+fs_initcall(kapi_debugfs_init);
-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* [PATCH v5 04/11] tools/kapi: add kernel API specification extraction tool
  2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
                   ` (2 preceding siblings ...)
  2026-10-08  8:49 ` [PATCH v5 03/11] kernel/api: add debugfs interface for kernel " Sasha Levin
@ 2026-10-08  8:49 ` Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 05/11] kernel/api: add API specification for sys_open Sasha Levin
                   ` (6 subsequent siblings)
  10 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

The kapi tool extracts and renders kernel API specifications from
three input sources and emits them in one of three output formats:

  Input modes:
    --source PATH    parse kerneldoc blocks from a C source file or
                     directory
    --vmlinux PATH   decode the `.kapi_specs` ELF section from a
                     compiled kernel binary
    --debugfs PATH   read the spec dumps exposed under
                     /sys/kernel/debug/kapi/ on a running kernel

  Output formats: plain, json, rst

The tool is written in Rust and builds with cargo from the crates
pinned in Cargo.lock; the resulting binary has no runtime
dependencies. It ships alongside the kernel to give documentation
tools, static analyzers, and IDE integrations a single entry point for
querying the spec data produced by the framework.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 Documentation/dev-tools/kernel-api-spec.rst   |   74 +-
 tools/kapi/.gitignore                         |    3 +
 tools/kapi/Cargo.lock                         |  679 ++++
 tools/kapi/Cargo.toml                         |   20 +
 tools/kapi/Makefile                           |   33 +
 tools/kapi/README.md                          |   33 +
 tools/kapi/src/extractor/debugfs.rs           | 1704 +++++++++
 tools/kapi/src/extractor/kerneldoc_parser.rs  | 3335 +++++++++++++++++
 tools/kapi/src/extractor/mod.rs               |  442 +++
 tools/kapi/src/extractor/source_parser.rs     |  531 +++
 .../src/extractor/vmlinux/binary_utils.rs     |  461 +++
 tools/kapi/src/extractor/vmlinux/mod.rs       | 1160 ++++++
 tools/kapi/src/formatter/json.rs              |  659 ++++
 tools/kapi/src/formatter/mod.rs               |  220 ++
 tools/kapi/src/formatter/plain.rs             |  679 ++++
 tools/kapi/src/formatter/rst.rs               |  802 ++++
 tools/kapi/src/main.rs                        |  123 +
 17 files changed, 10886 insertions(+), 72 deletions(-)
 create mode 100644 tools/kapi/.gitignore
 create mode 100644 tools/kapi/Cargo.lock
 create mode 100644 tools/kapi/Cargo.toml
 create mode 100644 tools/kapi/Makefile
 create mode 100644 tools/kapi/README.md
 create mode 100644 tools/kapi/src/extractor/debugfs.rs
 create mode 100644 tools/kapi/src/extractor/kerneldoc_parser.rs
 create mode 100644 tools/kapi/src/extractor/mod.rs
 create mode 100644 tools/kapi/src/extractor/source_parser.rs
 create mode 100644 tools/kapi/src/extractor/vmlinux/binary_utils.rs
 create mode 100644 tools/kapi/src/extractor/vmlinux/mod.rs
 create mode 100644 tools/kapi/src/formatter/json.rs
 create mode 100644 tools/kapi/src/formatter/mod.rs
 create mode 100644 tools/kapi/src/formatter/plain.rs
 create mode 100644 tools/kapi/src/formatter/rst.rs
 create mode 100644 tools/kapi/src/main.rs

diff --git a/Documentation/dev-tools/kernel-api-spec.rst b/Documentation/dev-tools/kernel-api-spec.rst
index fa8aabb92a503..95b72060f8ef8 100644
--- a/Documentation/dev-tools/kernel-api-spec.rst
+++ b/Documentation/dev-tools/kernel-api-spec.rst
@@ -31,7 +31,8 @@ The framework aims to:
    common programming errors during development and testing.
 
 3. **Support Tooling**: Export API specifications in machine-readable formats for
-   use by static analyzers, documentation generators, and development tools.
+   use by static analyzers, documentation generators, and development tools. See
+   `The kapi Tool`_.
 
 4. **Formalize Contracts**: Explicitly document API contracts including parameter
    constraints, execution contexts, locking requirements, and side effects.
@@ -573,77 +574,6 @@ The tool supports all KAPI specification types:
 - System calls (kerneldoc annotations)
 - Kernel functions (kerneldoc annotations with KAPI tags)
 
-IDE Integration
----------------
-
-Modern IDEs can use the specification data for:
-
-- Parameter hints
-- Type checking
-- Context validation
-- Error code documentation
-
-Best Practices
-==============
-
-Writing Specifications
-----------------------
-
-1. **Be Comprehensive**: Document all parameters, errors, and side effects
-2. **Keep Updated**: Update specs when API behavior changes
-3. **Use Examples**: Include usage examples in descriptions
-4. **Validate Constraints**: Define realistic constraints for parameters
-5. **Document Context**: Clearly specify allowed execution contexts
-
-Maintenance
------------
-
-1. **Version Specifications**: Increment version when API changes
-2. **Deprecation**: Mark deprecated APIs and suggest replacements
-3. **Cross-reference**: Link related APIs in descriptions
-4. **Test Specifications**: Verify specs match implementation
-
-Common Patterns
----------------
-
-**Optional Parameters**:
-
-.. code-block:: c
-
-    /**
-     * @optional_arg: Optional argument (may be NULL)
-     *
-     * param: optional_arg
-     *   type: KAPI_TYPE_PTR
-     *   flags: KAPI_PARAM_IN | KAPI_PARAM_OPTIONAL
-     */
-
-**Buffer with Size Parameter**:
-
-.. code-block:: c
-
-    /**
-     * @buf: User-space buffer
-     *
-     * param: buf
-     *   type: KAPI_TYPE_USER_PTR
-     *   flags: KAPI_PARAM_OUT | KAPI_PARAM_USER
-     *   constraint-type: KAPI_CONSTRAINT_BUFFER
-     *   size-param: 2
-     */
-
-**Callback Functions**:
-
-.. code-block:: c
-
-    /**
-     * @callback: Callback function
-     *
-     * param: callback
-     *   type: KAPI_TYPE_FUNC_PTR
-     *   flags: KAPI_PARAM_IN
-     */
-
 Troubleshooting
 ===============
 
diff --git a/tools/kapi/.gitignore b/tools/kapi/.gitignore
new file mode 100644
index 0000000000000..fa95032ed6c2a
--- /dev/null
+++ b/tools/kapi/.gitignore
@@ -0,0 +1,3 @@
+# Rust build artifacts
+/target/
+**/*.rs.bk
diff --git a/tools/kapi/Cargo.lock b/tools/kapi/Cargo.lock
new file mode 100644
index 0000000000000..23d4ef8b910d2
--- /dev/null
+++ b/tools/kapi/Cargo.lock
@@ -0,0 +1,679 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "goblin"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "983a6aafb3b12d4c41ea78d39e189af4298ce747353945ff5105b54a056e5cd9"
+dependencies = [
+ "log",
+ "plain",
+ "scroll",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.0",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "kapi"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "goblin",
+ "regex",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "walkdir",
+]
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.185"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scroll"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add"
+dependencies = [
+ "scroll_derive",
+]
+
+[[package]]
+name = "scroll_derive"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/tools/kapi/Cargo.toml b/tools/kapi/Cargo.toml
new file mode 100644
index 0000000000000..159e65d324468
--- /dev/null
+++ b/tools/kapi/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "kapi"
+version = "0.1.0"
+edition = "2021"
+rust-version = "1.85"
+authors = ["Sasha Levin <sashal@kernel.org>"]
+description = "Tool for extracting and displaying kernel API specifications"
+license = "GPL-2.0"
+
+[dependencies]
+goblin = "0.10"
+clap = { version = "4.4", features = ["derive"] }
+anyhow = "1.0"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+regex = "1.10"
+walkdir = "2.4"
+
+[dev-dependencies]
+tempfile = "3.8"
diff --git a/tools/kapi/Makefile b/tools/kapi/Makefile
new file mode 100644
index 0000000000000..d4234538e4eee
--- /dev/null
+++ b/tools/kapi/Makefile
@@ -0,0 +1,33 @@
+# SPDX-License-Identifier: GPL-2.0
+# Makefile wrapper for the kapi tool (Rust userspace binary).
+#
+# See Documentation/dev-tools/kernel-api-spec.rst for details.
+
+PREFIX ?= /usr/local
+
+.PHONY: all build release debug clean install test fmt clippy
+
+all: release
+
+release:
+	cargo build --release
+
+build: release
+
+debug:
+	cargo build
+
+test:
+	cargo test
+
+fmt:
+	cargo fmt --all -- --check
+
+clippy:
+	cargo clippy --all-targets --all-features -- -D warnings
+
+clean:
+	cargo clean
+
+install: release
+	install -D -m 0755 target/release/kapi $(DESTDIR)$(PREFIX)/bin/kapi
diff --git a/tools/kapi/README.md b/tools/kapi/README.md
new file mode 100644
index 0000000000000..3839f3ff2964a
--- /dev/null
+++ b/tools/kapi/README.md
@@ -0,0 +1,33 @@
+# kapi — Kernel API Specification Extractor
+
+Userspace utility that extracts and displays kernel API specifications from
+three sources:
+
+- `--source PATH` — parse kerneldoc blocks in a C source file or tree
+- `--vmlinux PATH` — decode the `.kapi_specs` ELF section of a compiled vmlinux
+- `--debugfs PATH` — read the live specs from `/sys/kernel/debug/kapi/` on a
+  running kernel (this is also the mode used when no input option is given, in
+  which case PATH is `/sys/kernel/debug`)
+
+Output formats: `plain` (default), `json`, `rst`.
+
+See `Documentation/dev-tools/kernel-api-spec.rst` for the full user guide,
+including the kerneldoc DSL reference and the surrounding framework design.
+
+## Build
+
+```
+make -C tools/kapi
+```
+
+(wraps `cargo build --release`; the binary is produced at
+`tools/kapi/target/release/kapi`).
+
+## Usage
+
+```
+tools/kapi/target/release/kapi --help
+tools/kapi/target/release/kapi --source fs/open.c sys_open
+tools/kapi/target/release/kapi --vmlinux vmlinux -f json
+tools/kapi/target/release/kapi --debugfs /sys/kernel/debug
+```
diff --git a/tools/kapi/src/extractor/debugfs.rs b/tools/kapi/src/extractor/debugfs.rs
new file mode 100644
index 0000000000000..0a7e41e1c40ca
--- /dev/null
+++ b/tools/kapi/src/extractor/debugfs.rs
@@ -0,0 +1,1704 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use crate::formatter::OutputFormatter;
+use anyhow::{bail, Context, Result};
+use serde::Deserialize;
+use std::fs;
+use std::io::Write;
+use std::path::PathBuf;
+
+use super::{
+    display_api_spec, ApiExtractor, ApiSpec, CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec,
+    ParamSpec, ReturnSpec, SignalMaskSpec, StateTransitionSpec, StructFieldSpec, StructSpec,
+};
+
+// Schema matching what kapi_export_json() in the kernel emits.  The kernel
+// serialises several enum-like fields as hex strings ("0x%x") or token
+// strings ("exact", "process"); we keep them as Option<String> here and
+// interpret them during conversion.
+#[derive(Deserialize)]
+struct KernelApiJson {
+    name: String,
+    #[serde(default)]
+    api_type: Option<String>,
+    #[serde(default)]
+    version: Option<u32>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    long_description: Option<String>,
+    #[serde(default)]
+    context_flags: Option<String>,
+    #[serde(default)]
+    examples: Option<String>,
+    #[serde(default)]
+    notes: Option<String>,
+    #[serde(default)]
+    capabilities: Option<Vec<KernelCapabilityJson>>,
+    #[serde(default)]
+    parameters: Option<Vec<KernelParamJson>>,
+    #[serde(default)]
+    errors: Option<Vec<KernelErrorJson>>,
+    #[serde(default, rename = "return")]
+    return_spec: Option<KernelReturnJson>,
+    #[serde(default)]
+    locks: Option<Vec<KernelLockJson>>,
+    #[serde(default)]
+    constraints: Option<Vec<KernelConstraintJson>>,
+    #[serde(default)]
+    signals: Option<Vec<KernelSignalJson>>,
+    #[serde(default)]
+    side_effects: Option<Vec<KernelSideEffectJson>>,
+    #[serde(default)]
+    state_transitions: Option<Vec<KernelStateTransitionJson>>,
+    #[serde(default)]
+    signal_masks: Option<Vec<KernelSignalMaskJson>>,
+    #[serde(default)]
+    struct_specs: Option<Vec<KernelStructJson>>,
+}
+
+#[derive(Deserialize)]
+struct KernelStateTransitionJson {
+    #[serde(default)]
+    object: Option<String>,
+    #[serde(default)]
+    from_state: Option<String>,
+    #[serde(default)]
+    to_state: Option<String>,
+    #[serde(default)]
+    condition: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelSignalMaskJson {
+    #[serde(default)]
+    name: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    signals: Vec<i32>,
+}
+
+#[derive(Deserialize)]
+struct KernelStructFieldJson {
+    #[serde(default)]
+    name: Option<String>,
+    #[serde(rename = "type", default)]
+    type_name: Option<String>,
+    #[serde(default)]
+    type_class: Option<String>,
+    #[serde(default)]
+    offset: usize,
+    #[serde(default)]
+    size: usize,
+    #[serde(default)]
+    flags: Option<String>,
+    #[serde(default)]
+    constraint_type: Option<String>,
+    #[serde(default)]
+    min_value: i64,
+    #[serde(default)]
+    max_value: i64,
+    #[serde(default)]
+    valid_mask: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelStructJson {
+    #[serde(default)]
+    name: Option<String>,
+    #[serde(default)]
+    size: usize,
+    #[serde(default)]
+    alignment: usize,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    fields: Vec<KernelStructFieldJson>,
+}
+
+#[derive(Deserialize)]
+struct KernelConstraintJson {
+    name: String,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    expression: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelSignalJson {
+    #[serde(default)]
+    signal_num: i32,
+    #[serde(default)]
+    signal_name: Option<String>,
+    #[serde(default)]
+    direction: Option<String>,
+    #[serde(default)]
+    action: u32,
+    #[serde(default)]
+    target: Option<String>,
+    #[serde(default)]
+    condition: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    restartable: bool,
+    #[serde(default)]
+    sa_flags_required: Option<String>,
+    #[serde(default)]
+    sa_flags_forbidden: Option<String>,
+    #[serde(default)]
+    error_on_signal: i32,
+    #[serde(default)]
+    transform_to: i32,
+    #[serde(default)]
+    timing: Option<String>,
+    #[serde(default)]
+    priority: u32,
+    #[serde(default)]
+    interruptible: bool,
+    #[serde(default)]
+    queue_behavior: Option<String>,
+    #[serde(default)]
+    state_required: Option<String>,
+    #[serde(default)]
+    state_forbidden: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelSideEffectJson {
+    #[serde(rename = "type", default)]
+    type_hex: Option<String>,
+    #[serde(default)]
+    target: Option<String>,
+    #[serde(default)]
+    condition: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    reversible: bool,
+}
+
+#[derive(Deserialize)]
+struct KernelParamJson {
+    name: String,
+    #[serde(rename = "type", default)]
+    type_name: Option<String>,
+    #[serde(default)]
+    type_class: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    flags: Option<String>,
+    #[serde(default)]
+    constraint_type: Option<String>,
+    #[serde(default)]
+    constraint_desc: Option<String>,
+    #[serde(default)]
+    min_value: Option<i64>,
+    #[serde(default)]
+    max_value: Option<i64>,
+    #[serde(default)]
+    valid_mask: Option<String>,
+    #[serde(default)]
+    enum_values: Vec<i64>,
+    #[serde(default)]
+    size: Option<u64>,
+    #[serde(default)]
+    alignment: Option<u64>,
+    #[serde(default)]
+    size_param_idx: Option<u32>,
+}
+
+#[derive(Deserialize)]
+struct KernelErrorJson {
+    #[serde(rename = "code")]
+    error_code: i32,
+    #[serde(default)]
+    name: Option<String>,
+    #[serde(default)]
+    condition: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelReturnJson {
+    #[serde(rename = "type", default)]
+    type_name: Option<String>,
+    #[serde(default)]
+    type_class: Option<String>,
+    #[serde(default)]
+    check_type: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    success_value: Option<i64>,
+    #[serde(default)]
+    success_min: Option<i64>,
+    #[serde(default)]
+    success_max: Option<i64>,
+    #[serde(default)]
+    error_values: Vec<i64>,
+}
+
+#[derive(Deserialize)]
+struct KernelLockJson {
+    name: String,
+    #[serde(rename = "type", default)]
+    lock_type: Option<String>,
+    #[serde(default)]
+    scope: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelCapabilityJson {
+    capability: i32,
+    name: String,
+    action: String,
+    allows: String,
+    without_cap: String,
+    check_condition: Option<String>,
+    priority: Option<u8>,
+    alternatives: Option<Vec<i32>>,
+}
+
+/// Free-text fields of the text dump that may span several lines.
+#[derive(Clone, Copy)]
+enum TextField {
+    Description,
+    LongDescription,
+    Examples,
+    Notes,
+}
+
+impl TextField {
+    /// Recognise the header line of a text field, returning the field and
+    /// whatever follows the colon on the same line.
+    fn start(line: &str) -> Option<(Self, &str)> {
+        [
+            ("Description:", Self::Description),
+            ("Long description:", Self::LongDescription),
+            ("Examples:", Self::Examples),
+            ("Notes:", Self::Notes),
+        ]
+        .into_iter()
+        .find_map(|(header, field)| {
+            line.strip_prefix(header)
+                .map(|rest| (field, rest.trim_start()))
+        })
+    }
+
+    fn store(self, spec: &mut ApiSpec, lines: &[String]) {
+        let text = lines.join("\n");
+        let text = text.trim_matches('\n').trim_end().to_string();
+        let slot = match self {
+            Self::Description => &mut spec.description,
+            Self::LongDescription => &mut spec.long_description,
+            Self::Examples => &mut spec.examples,
+            Self::Notes => &mut spec.notes,
+        };
+        *slot = Some(text);
+    }
+}
+
+/// Extractor for kernel API specifications from debugfs
+pub struct DebugfsExtractor {
+    debugfs_path: PathBuf,
+}
+
+impl DebugfsExtractor {
+    /// Create a new debugfs extractor with the specified debugfs path
+    pub fn new(debugfs_path: Option<String>) -> Result<Self> {
+        let path = match debugfs_path {
+            Some(p) => PathBuf::from(p),
+            None => PathBuf::from("/sys/kernel/debug"),
+        };
+
+        // Check if the debugfs path exists
+        if !path.exists() {
+            bail!("Debugfs path does not exist: {}", path.display());
+        }
+
+        // Check if kapi directory exists
+        let kapi_path = path.join("kapi");
+        if !kapi_path.exists() {
+            bail!(
+                "Kernel API debugfs interface not found at: {}",
+                kapi_path.display()
+            );
+        }
+
+        Ok(Self { debugfs_path: path })
+    }
+
+    /// Parse the list file to get all available API names
+    fn parse_list_file(&self) -> Result<Vec<String>> {
+        let list_path = self.debugfs_path.join("kapi/list");
+        let content = fs::read_to_string(&list_path)
+            .with_context(|| format!("Failed to read {}", list_path.display()))?;
+
+        let mut apis = Vec::new();
+        let mut in_list = false;
+
+        for line in content.lines() {
+            if line.contains("===") {
+                in_list = true;
+                continue;
+            }
+
+            if in_list && line.starts_with("Total:") {
+                break;
+            }
+
+            if in_list && !line.trim().is_empty() {
+                // Extract API name from lines like "sys_read - Read from a file descriptor"
+                if let Some(name) = line.split(" - ").next() {
+                    apis.push(name.trim().to_string());
+                }
+            }
+        }
+
+        Ok(apis)
+    }
+
+    /// Convert context flags (emitted by the kernel as a hex string like
+    /// "0x21") into the token list consumed by the formatter.
+    fn parse_context_flags(flags: &str) -> Vec<String> {
+        let mut result = Vec::new();
+        let bits = flags
+            .strip_prefix("0x")
+            .or_else(|| flags.strip_prefix("0X"))
+            .unwrap_or(flags);
+        let Ok(flags) = u32::from_str_radix(bits, 16) else {
+            return result;
+        };
+
+        // These values should match KAPI_CTX_* flags from kernel
+        if flags & (1 << 0) != 0 {
+            result.push("KAPI_CTX_PROCESS".to_string());
+        }
+        if flags & (1 << 1) != 0 {
+            result.push("KAPI_CTX_SOFTIRQ".to_string());
+        }
+        if flags & (1 << 2) != 0 {
+            result.push("KAPI_CTX_HARDIRQ".to_string());
+        }
+        if flags & (1 << 3) != 0 {
+            result.push("KAPI_CTX_NMI".to_string());
+        }
+        if flags & (1 << 4) != 0 {
+            result.push("KAPI_CTX_ATOMIC".to_string());
+        }
+        if flags & (1 << 5) != 0 {
+            result.push("KAPI_CTX_SLEEPABLE".to_string());
+        }
+        if flags & (1 << 6) != 0 {
+            result.push("KAPI_CTX_PREEMPT_DISABLED".to_string());
+        }
+        if flags & (1 << 7) != 0 {
+            result.push("KAPI_CTX_IRQ_DISABLED".to_string());
+        }
+
+        result
+    }
+
+    /// Parse a hex-string like "0x123" into u64, returning 0 on failure.
+    fn parse_hex_u64(value: &str) -> u64 {
+        let bits = value
+            .strip_prefix("0x")
+            .or_else(|| value.strip_prefix("0X"))
+            .unwrap_or(value);
+        u64::from_str_radix(bits, 16).unwrap_or(0)
+    }
+
+    /// Parse a hex-string like "0x123" into u32, returning 0 on failure.
+    fn parse_hex_u32(value: &str) -> u32 {
+        u32::try_from(Self::parse_hex_u64(value)).unwrap_or(0)
+    }
+
+    /// Map the type-class token emitted by the kernel (param_type_to_string)
+    /// back to the numeric enum kapi_param_type.
+    fn parse_type_class(token: &str) -> u32 {
+        match token {
+            "void" => 0,
+            "int" => 1,
+            "uint" => 2,
+            "pointer" => 3,
+            "struct" => 4,
+            "union" => 5,
+            "enum" => 6,
+            "function_pointer" => 7,
+            "array" => 8,
+            "file_descriptor" => 9,
+            "user_pointer" => 10,
+            "pathname" => 11,
+            "custom" => 12,
+            _ => 0,
+        }
+    }
+
+    /// Map the constraint-type token emitted by the kernel
+    /// (constraint_type_to_string) back to the numeric enum
+    /// kapi_constraint_type.
+    fn parse_constraint_type(token: &str) -> u32 {
+        match token {
+            "none" => 0,
+            "range" => 1,
+            "mask" => 2,
+            "enum" => 3,
+            "alignment" => 4,
+            "power_of_two" => 5,
+            "page_aligned" => 6,
+            "nonzero" => 7,
+            "user_string" => 8,
+            "user_path" => 9,
+            "user_ptr" => 10,
+            "buffer" => 11,
+            "custom" => 12,
+            _ => 0,
+        }
+    }
+
+    /// Derive the API type from the symbol name, the same way the vmlinux
+    /// extractor does.
+    fn api_type_from_name(name: &str) -> &'static str {
+        if name.starts_with("sys_") {
+            "syscall"
+        } else if name.ends_with("_ioctl") {
+            "ioctl"
+        } else if name.contains("sysfs") {
+            "sysfs"
+        } else {
+            "function"
+        }
+    }
+
+    /// Map the check-type token emitted by the kernel
+    /// (return_check_type_to_string) back to the u32 enum the formatter wants.
+    fn parse_check_type(token: &str) -> u32 {
+        match token {
+            "exact" => 0,
+            "range" => 1,
+            "error_check" => 2,
+            "file_descriptor" => 3,
+            "custom" => 4,
+            "no_return" => 5,
+            _ => 0,
+        }
+    }
+
+    /// Map the lock-type token emitted by the kernel (lock_type_to_string).
+    fn parse_lock_type(token: &str) -> u32 {
+        match token {
+            "none" => 0,
+            "mutex" => 1,
+            "spinlock" => 2,
+            "rwlock" => 3,
+            "seqlock" => 4,
+            "rcu" => 5,
+            "semaphore" => 6,
+            "custom" => 7,
+            _ => 0,
+        }
+    }
+
+    /// Map the lock-scope token emitted by the kernel (lock_scope_to_string).
+    fn parse_lock_scope(token: &str) -> u32 {
+        match token {
+            "internal" => 0,
+            "acquires" => 1,
+            "releases" => 2,
+            "caller_held" => 3,
+            _ => 0,
+        }
+    }
+
+    /// Map the signal-timing token (e.g. "during") back to the u32 enum that
+    /// the source-side parser produces. Mirrors
+    /// KerneldocParser::parse_signal_timing in kerneldoc_parser.rs so the
+    /// debugfs path and the source path agree.
+    fn parse_signal_timing(token: &str) -> u32 {
+        match token.trim().to_ascii_lowercase().as_str() {
+            "before" => 0,
+            "during" => 1,
+            "after" => 2,
+            _ => 0,
+        }
+    }
+
+    /// Convert the capability action token emitted by the kernel
+    /// (capability_action_to_string) to the KAPI_CAP_* spelling used by the
+    /// other extractors.
+    fn parse_capability_action(action: &str) -> String {
+        match action {
+            "bypass_check" => "KAPI_CAP_BYPASS_CHECK".to_string(),
+            "increase_limit" => "KAPI_CAP_INCREASE_LIMIT".to_string(),
+            "override_restriction" => "KAPI_CAP_OVERRIDE_RESTRICTION".to_string(),
+            "grant_permission" => "KAPI_CAP_GRANT_PERMISSION".to_string(),
+            "modify_behavior" => "KAPI_CAP_MODIFY_BEHAVIOR".to_string(),
+            "access_resource" => "KAPI_CAP_ACCESS_RESOURCE".to_string(),
+            "perform_operation" => "KAPI_CAP_PERFORM_OPERATION".to_string(),
+            _ => action.to_string(),
+        }
+    }
+
+    /// Try to parse as JSON first
+    fn try_parse_json(&self, content: &str) -> Result<ApiSpec, serde_json::Error> {
+        let json_data: KernelApiJson = serde_json::from_str(content)?;
+        // The kernel-side kapi_json_str() emits NULL char * as the empty
+        // string "", so normalise empty -> None to match the ApiSpec
+        // convention used by --source / --vmlinux.
+        fn opt_str(s: Option<String>) -> Option<String> {
+            s.filter(|v| !v.is_empty())
+        }
+        let api_type = json_data
+            .api_type
+            .unwrap_or_else(|| Self::api_type_from_name(&json_data.name).to_string());
+
+        let mut spec = ApiSpec {
+            name: json_data.name,
+            api_type,
+            description: opt_str(json_data.description),
+            long_description: opt_str(json_data.long_description),
+            version: json_data.version.map(|v| v.to_string()),
+            context_flags: json_data
+                .context_flags
+                .as_deref()
+                .map_or_else(Vec::new, Self::parse_context_flags),
+            param_count: None,
+            error_count: None,
+            examples: opt_str(json_data.examples),
+            notes: opt_str(json_data.notes),
+            subsystem: None,   // Not in the JSON format
+            sysfs_path: None,  // Not in the JSON format
+            permissions: None, // Not in the JSON format
+            capabilities: vec![],
+            parameters: vec![],
+            return_spec: None,
+            errors: vec![],
+            signals: vec![],
+            signal_masks: vec![],
+            side_effects: vec![],
+            state_transitions: vec![],
+            constraints: vec![],
+            locks: vec![],
+            struct_specs: vec![],
+        };
+
+        // Convert capabilities
+        if let Some(caps) = json_data.capabilities {
+            for cap in caps {
+                spec.capabilities.push(CapabilitySpec {
+                    capability: cap.capability,
+                    name: cap.name,
+                    action: Self::parse_capability_action(&cap.action),
+                    allows: cap.allows,
+                    without_cap: cap.without_cap,
+                    check_condition: opt_str(cap.check_condition),
+                    priority: cap.priority,
+                    alternatives: cap.alternatives.unwrap_or_default(),
+                });
+            }
+        }
+
+        // Convert parameters.
+        if let Some(params) = json_data.parameters {
+            for (i, p) in params.into_iter().enumerate() {
+                let flags = p.flags.as_deref().map_or(0, Self::parse_hex_u32);
+                let mut param = ParamSpec {
+                    index: i as u32,
+                    name: p.name,
+                    type_name: p.type_name.unwrap_or_default(),
+                    description: p.description.unwrap_or_default(),
+                    flags,
+                    param_type: p.type_class.as_deref().map_or(0, Self::parse_type_class),
+                    constraint_type: p
+                        .constraint_type
+                        .as_deref()
+                        .map_or(0, Self::parse_constraint_type),
+                    constraint: opt_str(p.constraint_desc),
+                    min_value: p.min_value,
+                    max_value: p.max_value,
+                    valid_mask: p.valid_mask.as_deref().map(Self::parse_hex_u64),
+                    enum_values: p.enum_values.iter().map(i64::to_string).collect(),
+                    size: p.size.map(|v| v as u32),
+                    alignment: p.alignment.map(|v| v as u32),
+                    size_param_idx: p.size_param_idx,
+                };
+                param.keep_used_numbers();
+                spec.parameters.push(param);
+            }
+            if !spec.parameters.is_empty() {
+                spec.param_count = Some(spec.parameters.len() as u32);
+            }
+        }
+
+        // Convert errors
+        if let Some(errors) = json_data.errors {
+            for e in errors {
+                spec.errors.push(ErrorSpec {
+                    error_code: e.error_code,
+                    name: e.name.unwrap_or_default(),
+                    condition: e.condition.unwrap_or_default(),
+                    description: e.description.unwrap_or_default(),
+                });
+            }
+            if !spec.errors.is_empty() {
+                spec.error_count = Some(spec.errors.len() as u32);
+            }
+        }
+
+        // Convert return spec
+        if let Some(ret) = json_data.return_spec {
+            let check_type = ret.check_type.as_deref().map_or(0, Self::parse_check_type);
+            let return_type = ret.type_class.as_deref().map_or(0, Self::parse_type_class);
+            let type_name = ret.type_name.unwrap_or_default();
+            let success_value = ret.success_value.unwrap_or(0);
+            // A spec without a return block exports as an all-zero return
+            // spec; report it as absent like the vmlinux extractor does.
+            if !(type_name.is_empty() && return_type == 0 && check_type == 0 && success_value == 0)
+            {
+                let mut ret_spec = ReturnSpec {
+                    type_name,
+                    description: ret.description.unwrap_or_default(),
+                    return_type,
+                    check_type,
+                    success_value: ret.success_value,
+                    success_min: ret.success_min,
+                    success_max: ret.success_max,
+                    error_values: ret
+                        .error_values
+                        .into_iter()
+                        .filter_map(|v| i32::try_from(v).ok())
+                        .collect(),
+                };
+                ret_spec.keep_used_success_fields();
+                spec.return_spec = Some(ret_spec);
+            }
+        }
+
+        // Convert locks
+        if let Some(locks) = json_data.locks {
+            for l in locks {
+                let lock_type = l.lock_type.as_deref().map_or(0, Self::parse_lock_type);
+                let scope = l.scope.as_deref().map_or(0, Self::parse_lock_scope);
+                spec.locks.push(LockSpec {
+                    lock_name: l.name,
+                    lock_type,
+                    scope,
+                    description: l.description.unwrap_or_default(),
+                });
+            }
+        }
+
+        // Convert constraints.  Empty strings emitted from kapi_json_str()
+        // for NULL char * fields normalise back to None to match --source.
+        if let Some(constraints) = json_data.constraints {
+            for c in constraints {
+                spec.constraints.push(ConstraintSpec {
+                    name: c.name,
+                    description: c.description.unwrap_or_default(),
+                    expression: c.expression.filter(|v| !v.is_empty()),
+                });
+            }
+        }
+
+        // Convert signals.
+        if let Some(signals) = json_data.signals {
+            for s in signals {
+                let direction = s.direction.as_deref().map_or(0, Self::parse_hex_u32);
+                let sa_flags_required = s
+                    .sa_flags_required
+                    .as_deref()
+                    .map_or(0, Self::parse_hex_u32);
+                let sa_flags_forbidden = s
+                    .sa_flags_forbidden
+                    .as_deref()
+                    .map_or(0, Self::parse_hex_u32);
+                let state_required = s.state_required.as_deref().map_or(0, Self::parse_hex_u32);
+                let state_forbidden = s.state_forbidden.as_deref().map_or(0, Self::parse_hex_u32);
+                let timing = s.timing.as_deref().map_or(0, Self::parse_signal_timing);
+                spec.signals.push(super::SignalSpec {
+                    signal_num: s.signal_num,
+                    signal_name: s.signal_name.unwrap_or_default(),
+                    direction,
+                    action: s.action,
+                    target: opt_str(s.target),
+                    condition: opt_str(s.condition),
+                    description: opt_str(s.description),
+                    timing,
+                    priority: s.priority,
+                    restartable: s.restartable,
+                    interruptible: s.interruptible,
+                    queue: opt_str(s.queue_behavior),
+                    sa_flags: 0,
+                    sa_flags_required,
+                    sa_flags_forbidden,
+                    state_required,
+                    state_forbidden,
+                    error_on_signal: if s.error_on_signal != 0 {
+                        Some(s.error_on_signal)
+                    } else {
+                        None
+                    },
+                    transform_to: if s.transform_to != 0 {
+                        // Kernel JSON already carries the numeric value.
+                        Some(s.transform_to)
+                    } else {
+                        None
+                    },
+                });
+            }
+        }
+
+        // Convert side effects.
+        if let Some(effects) = json_data.side_effects {
+            for e in effects {
+                let effect_type = e.type_hex.as_deref().map_or(0, Self::parse_hex_u32);
+                spec.side_effects.push(super::SideEffectSpec {
+                    effect_type,
+                    target: e.target.unwrap_or_default(),
+                    condition: e.condition.filter(|v| !v.is_empty()),
+                    description: e.description.unwrap_or_default(),
+                    reversible: e.reversible,
+                });
+            }
+        }
+
+        if let Some(transitions) = json_data.state_transitions {
+            for t in transitions {
+                spec.state_transitions.push(StateTransitionSpec {
+                    object: t.object.unwrap_or_default(),
+                    from_state: t.from_state.unwrap_or_default(),
+                    to_state: t.to_state.unwrap_or_default(),
+                    condition: opt_str(t.condition),
+                    description: t.description.unwrap_or_default(),
+                });
+            }
+        }
+
+        if let Some(masks) = json_data.signal_masks {
+            for m in masks {
+                spec.signal_masks.push(SignalMaskSpec {
+                    name: m.name.unwrap_or_default(),
+                    description: m.description.unwrap_or_default(),
+                    signals: m.signals,
+                });
+            }
+        }
+
+        if let Some(structs) = json_data.struct_specs {
+            for st in structs {
+                let fields: Vec<StructFieldSpec> = st
+                    .fields
+                    .into_iter()
+                    .map(|f| StructFieldSpec {
+                        name: f.name.unwrap_or_default(),
+                        field_type: f.type_class.as_deref().map_or(0, Self::parse_type_class),
+                        type_name: f.type_name.unwrap_or_default(),
+                        offset: f.offset,
+                        size: f.size,
+                        flags: f.flags.as_deref().map_or(0, Self::parse_hex_u32),
+                        constraint_type: f
+                            .constraint_type
+                            .as_deref()
+                            .map_or(0, Self::parse_constraint_type),
+                        min_value: f.min_value,
+                        max_value: f.max_value,
+                        valid_mask: f.valid_mask.as_deref().map_or(0, Self::parse_hex_u64),
+                        description: f.description.unwrap_or_default(),
+                    })
+                    .collect();
+                spec.struct_specs.push(StructSpec {
+                    name: st.name.unwrap_or_default(),
+                    size: st.size,
+                    alignment: st.alignment,
+                    field_count: fields.len() as u32,
+                    fields,
+                    description: st.description.unwrap_or_default(),
+                });
+            }
+        }
+
+        Ok(spec)
+    }
+
+    /// Parse a single API specification file
+    fn parse_spec_file(&self, api_name: &str) -> Result<ApiSpec> {
+        // Prefer the JSON endpoint; fall back to the plain-text dump under
+        // kapi/specs/ if it is missing or does not parse.
+        let json_path = self
+            .debugfs_path
+            .join(format!("kapi/specs-json/{}", api_name));
+        match fs::read_to_string(&json_path) {
+            Ok(content) => match self.try_parse_json(&content) {
+                Ok(spec) => return Ok(spec),
+                Err(e) => eprintln!(
+                    "Warning: invalid JSON in {}: {}; using the text dump",
+                    json_path.display(),
+                    e
+                ),
+            },
+            Err(e) if e.kind() != std::io::ErrorKind::NotFound => eprintln!(
+                "Warning: cannot read {}: {}; using the text dump",
+                json_path.display(),
+                e
+            ),
+            Err(_) => {}
+        }
+
+        let spec_path = self.debugfs_path.join(format!("kapi/specs/{}", api_name));
+        let content = fs::read_to_string(&spec_path)
+            .with_context(|| format!("Failed to read {}", spec_path.display()))?;
+
+        // The specs/ file may hold JSON as well.
+        if let Ok(spec) = self.try_parse_json(&content) {
+            return Ok(spec);
+        }
+
+        // Fall back to plain text parsing
+        let mut spec = ApiSpec {
+            name: api_name.to_string(),
+            api_type: "unknown".to_string(),
+            description: None,
+            long_description: None,
+            version: None,
+            context_flags: Vec::new(),
+            param_count: None,
+            error_count: None,
+            examples: None,
+            notes: None,
+            subsystem: None,
+            sysfs_path: None,
+            permissions: None,
+            capabilities: vec![],
+            parameters: vec![],
+            return_spec: None,
+            errors: vec![],
+            signals: vec![],
+            signal_masks: vec![],
+            side_effects: vec![],
+            state_transitions: vec![],
+            constraints: vec![],
+            locks: vec![],
+            struct_specs: vec![],
+        };
+
+        // Parse the content
+        let mut text_field: Option<TextField> = None;
+        let mut text_lines: Vec<String> = Vec::new();
+        let mut parsing_capability = false;
+        let mut in_capabilities_section = false;
+        let mut current_capability: Option<CapabilitySpec> = None;
+
+        for line in content.lines() {
+            // The kernel indents every line of a multi-line value, so a value
+            // ends at the first non-blank line that is not indented.
+            if let Some(field) = text_field {
+                if line.is_empty() {
+                    text_lines.push(String::new());
+                    continue;
+                }
+                if let Some(rest) = line.strip_prefix("  ") {
+                    text_lines.push(rest.to_string());
+                    continue;
+                }
+                field.store(&mut spec, &text_lines);
+                text_field = None;
+            }
+
+            // Handle capability sections
+            if line.starts_with("Capabilities (") {
+                in_capabilities_section = true;
+                continue;
+            }
+            // Any other top-level section header ends the capabilities section
+            // so that "  pending_signals (0):" inside "Signal handling (1):"
+            // isn't mis-parsed as a capability entry.
+            if !line.starts_with(' ') && !line.is_empty() && line.ends_with(':') {
+                in_capabilities_section = false;
+            }
+            if in_capabilities_section
+                && line.starts_with("  ")
+                && line.contains(" (")
+                && line.ends_with("):")
+            {
+                // Start of a capability entry like "  CAP_IPC_LOCK (14):"
+                if let Some(cap) = current_capability.take() {
+                    spec.capabilities.push(cap);
+                }
+
+                let parts: Vec<&str> = line.trim().split(" (").collect();
+                if parts.len() == 2 {
+                    let cap_name = parts[0].to_string();
+                    let cap_id = parts[1].trim_end_matches("):").parse().unwrap_or(0);
+                    current_capability = Some(CapabilitySpec {
+                        capability: cap_id,
+                        name: cap_name,
+                        action: String::new(),
+                        allows: String::new(),
+                        without_cap: String::new(),
+                        check_condition: None,
+                        priority: None,
+                        alternatives: Vec::new(),
+                    });
+                    parsing_capability = true;
+                }
+                continue;
+            }
+            if parsing_capability && line.starts_with("    ") {
+                // Parse capability fields
+                if let Some(ref mut cap) = current_capability {
+                    if let Some(action) = line.strip_prefix("    Action: ") {
+                        cap.action = action.to_string();
+                    } else if let Some(allows) = line.strip_prefix("    Allows: ") {
+                        cap.allows = allows.to_string();
+                    } else if let Some(without) = line.strip_prefix("    Without: ") {
+                        cap.without_cap = without.to_string();
+                    } else if let Some(cond) = line.strip_prefix("    Condition: ") {
+                        cap.check_condition = Some(cond.to_string());
+                    } else if let Some(prio) = line.strip_prefix("    Priority: ") {
+                        cap.priority = prio.parse().ok();
+                    } else if let Some(alts) = line.strip_prefix("    Alternatives: ") {
+                        cap.alternatives =
+                            alts.split(", ").filter_map(|s| s.parse().ok()).collect();
+                    }
+                }
+                continue;
+            }
+            if parsing_capability && !line.starts_with("  ") {
+                // End of capabilities section
+                if let Some(cap) = current_capability.take() {
+                    spec.capabilities.push(cap);
+                }
+                parsing_capability = false;
+            }
+
+            // Handle section headers
+            if line.starts_with("Parameters (") {
+                if let Some(count_str) = line
+                    .strip_prefix("Parameters (")
+                    .and_then(|s| s.strip_suffix("):"))
+                {
+                    spec.param_count = count_str.parse().ok();
+                }
+                continue;
+            } else if line.starts_with("Errors (") {
+                if let Some(count_str) = line
+                    .strip_prefix("Errors (")
+                    .and_then(|s| s.strip_suffix("):"))
+                {
+                    spec.error_count = count_str.parse().ok();
+                }
+                continue;
+            }
+
+            // Parse regular fields
+            if let Some((field, first)) = TextField::start(line) {
+                text_field = Some(field);
+                text_lines.clear();
+                if !first.is_empty() {
+                    text_lines.push(first.to_string());
+                }
+            } else if let Some(version) = line.strip_prefix("Version: ") {
+                spec.version = Some(version.to_string());
+            } else if let Some(flags) = line.strip_prefix("Context flags: ") {
+                spec.context_flags = flags
+                    .split_whitespace()
+                    .map(|f| format!("KAPI_CTX_{f}"))
+                    .collect();
+            } else if let Some(subsys) = line.strip_prefix("Subsystem: ") {
+                spec.subsystem = Some(subsys.to_string());
+            } else if let Some(path) = line.strip_prefix("Sysfs Path: ") {
+                spec.sysfs_path = Some(path.to_string());
+            } else if let Some(perms) = line.strip_prefix("Permissions: ") {
+                spec.permissions = Some(perms.to_string());
+            }
+        }
+
+        if let Some(field) = text_field {
+            field.store(&mut spec, &text_lines);
+        }
+
+        // Handle any remaining capability
+        if let Some(cap) = current_capability.take() {
+            spec.capabilities.push(cap);
+        }
+
+        // Determine API type based on name
+        if api_name.starts_with("sys_") {
+            spec.api_type = "syscall".to_string();
+        } else if api_name.contains("_ioctl") || api_name.starts_with("ioctl_") {
+            spec.api_type = "ioctl".to_string();
+        } else if api_name.contains("sysfs")
+            || api_name.ends_with("_show")
+            || api_name.ends_with("_store")
+        {
+            spec.api_type = "sysfs".to_string();
+        } else {
+            spec.api_type = "function".to_string();
+        }
+
+        Ok(spec)
+    }
+}
+
+impl ApiExtractor for DebugfsExtractor {
+    fn extract_all(&self) -> Result<Vec<ApiSpec>> {
+        let api_names = self.parse_list_file()?;
+        let mut specs = Vec::new();
+
+        for name in api_names {
+            match self.parse_spec_file(&name) {
+                Ok(spec) => specs.push(spec),
+                Err(e) => {
+                    eprintln!("Warning: failed to parse API spec '{}': {}", name, e);
+                }
+            }
+        }
+
+        Ok(specs)
+    }
+
+    fn extract_by_name(&self, name: &str) -> Result<Option<ApiSpec>> {
+        let api_names = self.parse_list_file()?;
+
+        if api_names.contains(&name.to_string()) {
+            Ok(Some(self.parse_spec_file(name)?))
+        } else {
+            Ok(None)
+        }
+    }
+
+    fn display_api_details(
+        &self,
+        api_name: &str,
+        formatter: &mut dyn OutputFormatter,
+        writer: &mut dyn Write,
+    ) -> Result<()> {
+        if let Some(spec) = self.extract_by_name(api_name)? {
+            display_api_spec(&spec, formatter, writer)?;
+        } else {
+            writeln!(writer, "API '{api_name}' not found in debugfs")?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // Shortened sys_read dump as emitted by kapi_export_json().
+    const SYS_READ_JSON: &str = r#"{
+  "name": "sys_read",
+  "version": 1,
+  "description": "Read data from a file descriptor",
+  "long_description": "",
+  "context_flags": "0x21",
+  "parameters": [
+    {
+      "name": "fd",
+      "type": "unsigned int fd",
+      "type_class": "file_descriptor",
+      "flags": "0x1",
+      "description": "File descriptor to read from ",
+      "constraint_type": "range",
+      "constraint_desc": "Must be a valid, open file descriptor",
+      "min_value": 0,
+      "max_value": 2147483647,
+      "valid_mask": "0x0",
+      "enum_values": [],
+      "size": 0,
+      "alignment": 0,
+      "size_param_idx": null,
+      "size_multiplier": 0
+    },
+    {
+      "name": "buf",
+      "type": "char __user * buf",
+      "type_class": "user_pointer",
+      "flags": "0x42",
+      "description": "User-space buffer to read data into ",
+      "constraint_type": "buffer",
+      "constraint_desc": "Must point to a writable user-space region",
+      "min_value": 0,
+      "max_value": 0,
+      "valid_mask": "0x0",
+      "enum_values": [],
+      "size": 0,
+      "alignment": 0,
+      "size_param_idx": 2,
+      "size_multiplier": 0
+    },
+    {
+      "name": "count",
+      "type": "size_t count",
+      "type_class": "uint",
+      "flags": "0x1",
+      "description": "Maximum number of bytes to read ",
+      "constraint_type": "none",
+      "constraint_desc": "",
+      "min_value": 0,
+      "max_value": 0,
+      "valid_mask": "0x0",
+      "enum_values": [],
+      "size": 0,
+      "alignment": 0,
+      "size_param_idx": null,
+      "size_multiplier": 0
+    }
+  ],
+  "return": {
+    "type": "KAPI_TYPE_INT",
+    "type_class": "int",
+    "check_type": "range",
+    "success_value": 0,
+    "success_min": 0,
+    "success_max": 9223372036854775807,
+    "error_values": [],
+    "description": "Number of bytes read"
+  },
+  "errors": [
+    { "code": -9, "name": "EBADF", "condition": "Bad file descriptor",
+      "description": "fd is not valid" }
+  ],
+  "locks": [
+    { "name": "file->f_pos_lock", "type": "mutex", "scope": "internal",
+      "description": "Position lock" }
+  ],
+  "capabilities": [
+    {
+      "capability": 1,
+      "name": "CAP_DAC_OVERRIDE",
+      "action": "bypass_check",
+      "allows": "Bypass read permission checks",
+      "without_cap": "Standard DAC checks are enforced",
+      "check_condition": "",
+      "priority": 0
+    }
+  ],
+  "constraints": [
+    { "name": "MAX_RW_COUNT",
+      "description": "Count is clamped", "expression": "min(count, MAX_RW_COUNT)" }
+  ],
+  "signals": [
+    {
+      "signal_num": 0,
+      "signal_name": "Any signal",
+      "direction": "0x1",
+      "action": 6,
+      "target": "",
+      "condition": "When blocked waiting for data",
+      "description": "May be interrupted",
+      "restartable": true,
+      "sa_flags_required": "0x0",
+      "sa_flags_forbidden": "0x0",
+      "error_on_signal": -4,
+      "transform_to": 0,
+      "timing": "during",
+      "priority": 0,
+      "interruptible": false,
+      "queue_behavior": "",
+      "state_required": "0x0",
+      "state_forbidden": "0x0"
+    }
+  ],
+  "side_effects": [
+    { "type": "0x10", "target": "file->f_pos", "condition": "",
+      "description": "Offset advances", "reversible": false }
+  ],
+  "state_transitions": [],
+  "signal_masks": [],
+  "struct_specs": [],
+  "examples": "",
+  "notes": ""
+}
+"#;
+
+    // Synthetic dump exercising the fields sys_read leaves empty: enum
+    // values, error values, struct specs, signal masks, state transitions.
+    const FULL_JSON: &str = r#"{
+  "name": "tmp_full",
+  "version": 1,
+  "description": "Full \"feature\" test\tline\nbreak\u0001",
+  "long_description": "",
+  "context_flags": "0x91",
+  "parameters": [
+    {
+      "name": "mode",
+      "type": "int mode",
+      "type_class": "enum",
+      "flags": "0x1",
+      "description": "Enumerated parameter",
+      "constraint_type": "enum",
+      "constraint_desc": "",
+      "min_value": 0,
+      "max_value": 0,
+      "valid_mask": "0x0",
+      "enum_values": [1, 2, -3, 1000000000000],
+      "size": 4096,
+      "alignment": 64,
+      "size_param_idx": null,
+      "size_multiplier": 0
+    },
+    {
+      "name": "bits",
+      "type": "u64 bits",
+      "type_class": "uint",
+      "flags": "0x1",
+      "description": "Masked parameter",
+      "constraint_type": "mask",
+      "constraint_desc": "",
+      "min_value": 0,
+      "max_value": 0,
+      "valid_mask": "0xffffffffffffffff",
+      "enum_values": [],
+      "size": 0,
+      "alignment": 0,
+      "size_param_idx": null,
+      "size_multiplier": 0
+    }
+  ],
+  "return": {
+    "type": "long",
+    "type_class": "int",
+    "check_type": "error_check",
+    "success_value": 0,
+    "success_min": 0,
+    "success_max": 0,
+    "error_values": [-1, -22, -4095],
+    "description": "Error-checked return"
+  },
+  "errors": [],
+  "locks": [
+    { "name": "tmp_lock", "type": "seqlock", "scope": "acquires", "description": "a seqlock" }
+  ],
+  "capabilities": [
+    {
+      "capability": 21,
+      "name": "CAP_SYS_ADMIN",
+      "action": "perform_operation",
+      "allows": "do it",
+      "without_cap": "cannot",
+      "check_condition": "always checked",
+      "priority": 3,
+      "alternatives": [21, 17]
+    }
+  ],
+  "constraints": [],
+  "signals": [],
+  "side_effects": [],
+  "state_transitions": [
+    { "object": "obj", "from_state": "a", "to_state": "b", "condition": "when asked",
+      "description": "moves a to b" }
+  ],
+  "signal_masks": [
+    { "name": "blocked", "description": "Blocked while running", "signals": [2, 15, 3] }
+  ],
+  "struct_specs": [
+    {
+      "name": "tmp_struct",
+      "size": 16,
+      "alignment": 8,
+      "description": "Test structure",
+      "fields": [
+        {
+          "name": "flags",
+          "type": "u32",
+          "type_class": "uint",
+          "offset": 0,
+          "size": 4,
+          "flags": "0x1",
+          "constraint_type": "mask",
+          "min_value": 0,
+          "max_value": 0,
+          "valid_mask": "0xff",
+          "enum_values": "",
+          "description": "Flag bits"
+        },
+        {
+          "name": "kind",
+          "type": "enum k",
+          "type_class": "enum",
+          "offset": 8,
+          "size": 4,
+          "flags": "0x0",
+          "constraint_type": "range",
+          "min_value": -5,
+          "max_value": 5,
+          "valid_mask": "0x0",
+          "enum_values": "",
+          "description": "Kind"
+        }
+      ]
+    }
+  ],
+  "examples": "",
+  "notes": "note"
+}
+"#;
+
+    // Dump without the optional per-parameter constraint fields.
+    const MINIMAL_JSON: &str = r#"{
+  "name": "sys_read",
+  "version": 1,
+  "description": "Read data from a file descriptor",
+  "long_description": "Reads.",
+  "context_flags": "0x21",
+  "parameters": [
+    {
+      "name": "buf",
+      "type": "char __user * buf",
+      "type_class": "user_pointer",
+      "flags": "0x42",
+      "description": "User-space buffer to read data into "
+    }
+  ],
+  "return": {
+    "type": "KAPI_TYPE_INT",
+    "type_class": "int",
+    "check_type": "range",
+    "success_min": 0,
+    "success_max": 9223372036854775807,
+    "description": "Bytes read"
+  },
+  "errors": [],
+  "locks": [],
+  "capabilities": [
+    {
+      "capability": 1,
+      "name": "CAP_DAC_OVERRIDE",
+      "action": "bypass_check",
+      "allows": "a",
+      "without_cap": "b",
+      "check_condition": "c",
+      "priority": 0
+    }
+  ],
+  "constraints": [],
+  "signals": [],
+  "side_effects": [],
+  "examples": "",
+  "notes": ""
+}
+"#;
+
+    fn extractor() -> DebugfsExtractor {
+        DebugfsExtractor {
+            debugfs_path: PathBuf::new(),
+        }
+    }
+
+    #[test]
+    fn json_sys_read_carries_type_and_constraint_data() {
+        let spec = extractor().try_parse_json(SYS_READ_JSON).unwrap();
+
+        assert_eq!(spec.api_type, "syscall");
+        assert_eq!(spec.version.as_deref(), Some("1"));
+        assert_eq!(spec.long_description, None);
+        assert_eq!(
+            spec.context_flags,
+            ["KAPI_CTX_PROCESS", "KAPI_CTX_SLEEPABLE"]
+        );
+        assert_eq!(spec.param_count, Some(3));
+
+        let fd = &spec.parameters[0];
+        assert_eq!((fd.param_type, fd.constraint_type), (9, 1));
+        assert_eq!((fd.min_value, fd.max_value), (Some(0), Some(2147483647)));
+        assert_eq!(fd.valid_mask, None);
+        assert_eq!(fd.size_param_idx, None);
+        assert_eq!(
+            fd.constraint.as_deref(),
+            Some("Must be a valid, open file descriptor")
+        );
+
+        let buf = &spec.parameters[1];
+        assert_eq!((buf.param_type, buf.constraint_type), (10, 11));
+        assert_eq!(buf.flags, 0x42);
+        assert_eq!(buf.size_param_idx, Some(2));
+        assert_eq!(
+            (buf.min_value, buf.max_value, buf.valid_mask),
+            (None, None, None)
+        );
+        assert_eq!((buf.size, buf.alignment), (None, None));
+        assert_eq!(
+            spec.parameters[buf.size_param_idx.unwrap() as usize].name,
+            "count"
+        );
+
+        let count = &spec.parameters[2];
+        assert_eq!((count.param_type, count.constraint_type), (2, 0));
+        assert_eq!(count.constraint, None);
+        assert_eq!(count.description, "Maximum number of bytes to read ");
+
+        let ret = spec.return_spec.as_ref().unwrap();
+        assert_eq!((ret.return_type, ret.check_type), (1, 1));
+        assert_eq!(ret.success_value, None);
+        assert_eq!(ret.success_min, Some(0));
+        assert_eq!(ret.success_max, Some(i64::MAX));
+        assert!(ret.error_values.is_empty());
+
+        assert_eq!(spec.errors[0].error_code, -9);
+        assert_eq!((spec.locks[0].lock_type, spec.locks[0].scope), (1, 0));
+        assert_eq!(spec.capabilities[0].action, "KAPI_CAP_BYPASS_CHECK");
+        assert_eq!(spec.capabilities[0].check_condition, None);
+        assert_eq!(spec.signals[0].timing, 1);
+        assert_eq!(spec.signals[0].error_on_signal, Some(-4));
+        assert_eq!(spec.signals[0].target, None);
+        assert_eq!(spec.side_effects[0].effect_type, 0x10);
+        assert_eq!(spec.side_effects[0].condition, None);
+        assert_eq!(spec.constraints.len(), 1);
+    }
+
+    #[test]
+    fn json_full_carries_enum_error_struct_and_transition_data() {
+        let spec = extractor().try_parse_json(FULL_JSON).unwrap();
+
+        assert_eq!(
+            spec.description.as_deref(),
+            Some("Full \"feature\" test\tline\nbreak\u{1}")
+        );
+        assert_eq!(
+            spec.context_flags,
+            [
+                "KAPI_CTX_PROCESS",
+                "KAPI_CTX_ATOMIC",
+                "KAPI_CTX_IRQ_DISABLED"
+            ]
+        );
+
+        let mode = &spec.parameters[0];
+        assert_eq!((mode.param_type, mode.constraint_type), (6, 3));
+        assert_eq!(mode.enum_values, ["1", "2", "-3", "1000000000000"]);
+        assert_eq!((mode.size, mode.alignment), (Some(4096), Some(64)));
+        assert_eq!(
+            (mode.min_value, mode.max_value, mode.valid_mask),
+            (None, None, None)
+        );
+
+        let bits = &spec.parameters[1];
+        assert_eq!(bits.constraint_type, 2);
+        assert_eq!(bits.valid_mask, Some(u64::MAX));
+        assert_eq!((bits.min_value, bits.max_value), (None, None));
+        assert_eq!((bits.size, bits.alignment), (None, None));
+
+        let ret = spec.return_spec.as_ref().unwrap();
+        assert_eq!(ret.check_type, 2);
+        assert_eq!(ret.error_values, [-1, -22, -4095]);
+
+        assert_eq!(spec.capabilities[0].alternatives, [21, 17]);
+        assert_eq!(spec.capabilities[0].priority, Some(3));
+        assert_eq!((spec.locks[0].lock_type, spec.locks[0].scope), (4, 1));
+
+        let trans = &spec.state_transitions[0];
+        assert_eq!(
+            (trans.object.as_str(), trans.from_state.as_str()),
+            ("obj", "a")
+        );
+        assert_eq!(trans.to_state, "b");
+        assert_eq!(trans.condition.as_deref(), Some("when asked"));
+
+        assert_eq!(spec.signal_masks[0].name, "blocked");
+        assert_eq!(spec.signal_masks[0].signals, [2, 15, 3]);
+
+        let st = &spec.struct_specs[0];
+        assert_eq!(
+            (st.name.as_str(), st.size, st.alignment),
+            ("tmp_struct", 16, 8)
+        );
+        assert_eq!(st.field_count, 2);
+        assert_eq!(st.description, "Test structure");
+        assert_eq!(
+            (st.fields[0].field_type, st.fields[0].constraint_type),
+            (2, 2)
+        );
+        assert_eq!(st.fields[0].valid_mask, 0xff);
+        assert_eq!(st.fields[1].offset, 8);
+        assert_eq!((st.fields[1].min_value, st.fields[1].max_value), (-5, 5));
+    }
+
+    #[test]
+    fn json_without_constraint_fields_still_parses() {
+        let spec = extractor().try_parse_json(MINIMAL_JSON).unwrap();
+
+        let buf = &spec.parameters[0];
+        assert_eq!(buf.param_type, 10);
+        assert_eq!(buf.constraint_type, 0);
+        assert_eq!(buf.min_value, None);
+        assert_eq!(buf.valid_mask, None);
+        assert_eq!(buf.size_param_idx, None);
+        assert!(buf.enum_values.is_empty());
+
+        let ret = spec.return_spec.as_ref().unwrap();
+        assert_eq!((ret.return_type, ret.check_type), (1, 1));
+        assert_eq!(ret.success_value, None);
+        assert_eq!(ret.success_max, Some(i64::MAX));
+        assert_eq!(spec.capabilities[0].action, "KAPI_CAP_BYPASS_CHECK");
+        assert!(spec.state_transitions.is_empty());
+        assert!(spec.struct_specs.is_empty());
+    }
+
+    #[test]
+    fn enum_tokens_match_kernel_numbering() {
+        let classes = [
+            "void",
+            "int",
+            "uint",
+            "pointer",
+            "struct",
+            "union",
+            "enum",
+            "function_pointer",
+            "array",
+            "file_descriptor",
+            "user_pointer",
+            "pathname",
+            "custom",
+        ];
+        for (n, token) in classes.iter().enumerate() {
+            assert_eq!(
+                DebugfsExtractor::parse_type_class(token),
+                n as u32,
+                "{token}"
+            );
+        }
+
+        let constraints = [
+            "none",
+            "range",
+            "mask",
+            "enum",
+            "alignment",
+            "power_of_two",
+            "page_aligned",
+            "nonzero",
+            "user_string",
+            "user_path",
+            "user_ptr",
+            "buffer",
+            "custom",
+        ];
+        for (n, token) in constraints.iter().enumerate() {
+            assert_eq!(
+                DebugfsExtractor::parse_constraint_type(token),
+                n as u32,
+                "{token}"
+            );
+        }
+    }
+
+    #[test]
+    fn api_type_follows_symbol_name() {
+        assert_eq!(DebugfsExtractor::api_type_from_name("sys_read"), "syscall");
+        assert_eq!(DebugfsExtractor::api_type_from_name("foo_ioctl"), "ioctl");
+        assert_eq!(DebugfsExtractor::api_type_from_name("kmalloc"), "function");
+    }
+
+    #[test]
+    fn truncated_json_is_rejected() {
+        let cut = &SYS_READ_JSON[..SYS_READ_JSON.len() / 2];
+        assert!(extractor().try_parse_json(cut).is_err());
+    }
+
+    fn debugfs_with(json: &str, text: &str) -> tempfile::TempDir {
+        let dir = tempfile::tempdir().unwrap();
+        let kapi = dir.path().join("kapi");
+        fs::create_dir_all(kapi.join("specs")).unwrap();
+        fs::create_dir_all(kapi.join("specs-json")).unwrap();
+        fs::write(
+            kapi.join("list"),
+            "Available Kernel API Specifications\n\
+             ===================================\n\n\
+             sys_read - Read data from a file descriptor\n\n\
+             Total: 1 specifications\n",
+        )
+        .unwrap();
+        fs::write(kapi.join("specs-json/sys_read"), json).unwrap();
+        fs::write(kapi.join("specs/sys_read"), text).unwrap();
+        dir
+    }
+
+    #[test]
+    fn reads_json_endpoint_from_debugfs_tree() {
+        let dir = debugfs_with(SYS_READ_JSON, "Name: sys_read\n");
+        let ex = DebugfsExtractor::new(Some(dir.path().to_string_lossy().into_owned())).unwrap();
+        let spec = ex.extract_by_name("sys_read").unwrap().unwrap();
+
+        assert_eq!(spec.parameters[1].constraint_type, 11);
+        assert_eq!(spec.parameters[1].size_param_idx, Some(2));
+    }
+
+    #[test]
+    fn truncated_json_endpoint_falls_back_to_text_dump() {
+        let text = "Name: sys_read\n\
+                    Version: 1\n\
+                    Description: Read data from a file descriptor\n\
+                    Context flags: PROCESS SLEEPABLE \n";
+        let dir = debugfs_with(&SYS_READ_JSON[..SYS_READ_JSON.len() / 2], text);
+        let ex = DebugfsExtractor::new(Some(dir.path().to_string_lossy().into_owned())).unwrap();
+        let spec = ex.extract_by_name("sys_read").unwrap().unwrap();
+
+        assert!(spec.parameters.is_empty());
+        assert_eq!(
+            spec.context_flags,
+            ["KAPI_CTX_PROCESS", "KAPI_CTX_SLEEPABLE"]
+        );
+    }
+
+    #[test]
+    fn text_dump_multi_line_values_stay_in_their_field() {
+        let text = "Kernel API Specification\n\
+                    ========================\n\n\
+                    Name: sys_read\n\
+                    Version: 1\n\
+                    Description: Read data from a file descriptor\n\
+                    Long description:\n  \
+                    First paragraph.\n\n  \
+                    Notes: not a header\n  \
+                    Version: 9\n\
+                    Context flags: PROCESS SLEEPABLE \n\n\
+                    Parameters (1):\n  \
+                    [0] fd:\n    \
+                    description: File descriptor\n\n\
+                    Examples:\n  \
+                    read(fd, buf, n);\n  \
+                    close(fd);\n\n\
+                    Notes:\n  \
+                    one\n\n  \
+                    two\n\n";
+        let dir = debugfs_with("", text);
+        let ex = DebugfsExtractor::new(Some(dir.path().to_string_lossy().into_owned())).unwrap();
+        let spec = ex.extract_by_name("sys_read").unwrap().unwrap();
+
+        assert_eq!(spec.version.as_deref(), Some("1"));
+        assert_eq!(
+            spec.description.as_deref(),
+            Some("Read data from a file descriptor")
+        );
+        assert_eq!(
+            spec.long_description.as_deref(),
+            Some("First paragraph.\n\nNotes: not a header\nVersion: 9")
+        );
+        assert_eq!(
+            spec.context_flags,
+            ["KAPI_CTX_PROCESS", "KAPI_CTX_SLEEPABLE"]
+        );
+        assert_eq!(spec.param_count, Some(1));
+        assert_eq!(
+            spec.examples.as_deref(),
+            Some("read(fd, buf, n);\nclose(fd);")
+        );
+        assert_eq!(spec.notes.as_deref(), Some("one\n\ntwo"));
+    }
+}
diff --git a/tools/kapi/src/extractor/kerneldoc_parser.rs b/tools/kapi/src/extractor/kerneldoc_parser.rs
new file mode 100644
index 0000000000000..b8fae748470b0
--- /dev/null
+++ b/tools/kapi/src/extractor/kerneldoc_parser.rs
@@ -0,0 +1,3335 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::{
+    ApiSpec, CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec, ReturnSpec,
+    SideEffectSpec, SignalSpec, StateTransitionSpec,
+};
+use anyhow::Result;
+use std::collections::HashMap;
+
+/// Kerneldoc parser that extracts KAPI annotations
+pub struct KerneldocParser;
+
+/// What block are we currently inside?
+#[derive(Debug, Clone, PartialEq)]
+enum BlockContext {
+    None,
+    Param(String),   // param: <name>
+    Error(String),   // error: <name>
+    Signal,          // signal: <name>
+    Capability,      // capability: <name>
+    SideEffect,      // side-effect: <type>
+    StateTransition, // state-trans: ...
+    Constraint,      // constraint: <name>
+    Lock,            // lock: <name>
+    Return,          // return:
+}
+
+/// Evaluate an integer literal exactly as the compiler does on LP64 (int is
+/// 32 bits, long and long long are 64): decimal, 0x/0X hex, 0b/0B binary or
+/// leading-0 octal, an optional u/U/l/L suffix and an optional leading '-'.
+/// The literal's C type (C11 6.4.4.1) decides how '-' wraps, so `-1U` is
+/// 4294967295 and `-0x80000000` is 2147483648. The result is the 64-bit
+/// two's complement pattern the value has once stored into an s64/u64
+/// field. Returns `None` for anything that requires cpp-level constant
+/// resolution (e.g. symbolic masks like `O_RDONLY | O_WRONLY`). Callers must
+/// treat that case as "value unknown" and leave the downstream slot unset,
+/// not store it as 0, which would wrongly assert that zero bits are valid.
+fn eval_c_int_literal(s: &str) -> Option<u64> {
+    let t = s.trim();
+    let (neg, t) = match t.strip_prefix('-') {
+        Some(rest) => (true, rest.trim_start()),
+        None => (false, t),
+    };
+    let body = t.trim_end_matches(['u', 'U', 'l', 'L']);
+    let suffix = &t[body.len()..];
+    let unsigned = match suffix.matches(['u', 'U']).count() {
+        0 => false,
+        1 => true,
+        _ => return None,
+    };
+    let longs = suffix.matches(['l', 'L']).count();
+    if longs > 2 {
+        return None;
+    }
+
+    let (digits, radix) =
+        if let Some(h) = body.strip_prefix("0x").or_else(|| body.strip_prefix("0X")) {
+            (h, 16)
+        } else if let Some(b) = body.strip_prefix("0b").or_else(|| body.strip_prefix("0B")) {
+            (b, 2)
+        } else if body.len() > 1 && body.starts_with('0') {
+            (&body[1..], 8)
+        } else {
+            (body, 10)
+        };
+    if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
+        return None;
+    }
+    let v = u64::from_str_radix(digits, radix).ok()?;
+
+    // Decimal literals without 'u' only take signed types; GCC treats one
+    // too large for long long as unsigned.
+    let value = if longs == 0 && !unsigned && v <= i32::MAX as u64 {
+        let x = v as i32;
+        (if neg { x.wrapping_neg() } else { x }) as i64 as u64
+    } else if longs == 0 && (unsigned || radix != 10) && v <= u32::MAX as u64 {
+        let x = v as u32;
+        u64::from(if neg { x.wrapping_neg() } else { x })
+    } else if !unsigned && v <= i64::MAX as u64 {
+        let x = v as i64;
+        (if neg { x.wrapping_neg() } else { x }) as u64
+    } else if neg {
+        v.wrapping_neg()
+    } else {
+        v
+    };
+    Some(value)
+}
+
+fn parse_u64_literal(s: &str) -> Option<u64> {
+    eval_c_int_literal(s)
+}
+
+fn parse_i64_literal(s: &str) -> Option<i64> {
+    eval_c_int_literal(s).map(|v| v as i64)
+}
+
+/// Split a return `success:` value into (exact value, range minimum,
+/// range maximum). `>= N` is the range [N, i64::MAX]; `N`, `= N` and
+/// `== N` are an exact value, and any value that is not `>= N` leaves
+/// the default range [0, i64::MAX] a range check falls back to. Mirrors
+/// `_return_success_macro()` in kdoc_apispec.py.
+fn parse_return_success(text: &str) -> (Option<i64>, Option<i64>, Option<i64>) {
+    let t = text.trim();
+    if let Some(min) = t.strip_prefix(">=") {
+        return (
+            None,
+            Some(parse_i64_literal(min).unwrap_or(0)),
+            Some(i64::MAX),
+        );
+    }
+    let exact = t
+        .strip_prefix("==")
+        .or_else(|| t.strip_prefix('='))
+        .unwrap_or(t);
+    (parse_i64_literal(exact), Some(0), Some(i64::MAX))
+}
+
+/// Canonicalise a capability `type:` value to its KAPI_CAP_* spelling.
+fn canon_kapi_cap_action(s: &str) -> String {
+    let t = s.trim();
+    if t.starts_with("KAPI_CAP_") {
+        return t.to_string();
+    }
+    match t.to_ascii_lowercase().as_str() {
+        "bypass_check" => "KAPI_CAP_BYPASS_CHECK".to_string(),
+        "increase_limit" => "KAPI_CAP_INCREASE_LIMIT".to_string(),
+        "override_restriction" => "KAPI_CAP_OVERRIDE_RESTRICTION".to_string(),
+        "grant_permission" => "KAPI_CAP_GRANT_PERMISSION".to_string(),
+        "modify_behavior" => "KAPI_CAP_MODIFY_BEHAVIOR".to_string(),
+        "access_resource" => "KAPI_CAP_ACCESS_RESOURCE".to_string(),
+        "perform_operation" => "KAPI_CAP_PERFORM_OPERATION".to_string(),
+        _ => t.to_string(),
+    }
+}
+
+/// Types whose semantics imply `KAPI_PARAM_USER` on the param, so
+/// `type: user_ptr, input` doesn't need a separate `user` flag.
+fn type_implies_user_flag(tok: &str) -> bool {
+    matches!(tok.trim(), "KAPI_TYPE_USER_PTR" | "KAPI_TYPE_PATH")
+        || matches!(
+            tok.trim().to_ascii_lowercase().as_str(),
+            "user_ptr" | "uptr" | "path"
+        )
+}
+
+/// Subfield names each block type consumes. An indented line opens a
+/// new subfield only when it starts with one of these followed by ':';
+/// any other line continues the previous subfield. Must stay in sync
+/// with the `_*_SUBFIELDS` sets in tools/lib/python/kdoc/kdoc_apispec.py.
+fn block_subfield_keys(block: &BlockContext) -> &'static [&'static str] {
+    match block {
+        BlockContext::Param(_) => &[
+            "type",
+            "flags",
+            "size",
+            "constraint-type",
+            "constraint",
+            "cdesc",
+            "range",
+            "mask",
+            "valid-mask",
+            "valid-values",
+            "alignment",
+            "size-param",
+            "struct-type",
+            "arch-mask",
+            "desc",
+            "description",
+        ],
+        BlockContext::Error(_) => &["desc", "condition"],
+        BlockContext::Signal => &[
+            "direction",
+            "action",
+            "condition",
+            "desc",
+            "errno",
+            "timing",
+            "priority",
+            "restartable",
+            "interruptible",
+            "number",
+            "target",
+            "queue",
+            "queue_behavior",
+            "transform",
+            "transform_to",
+            "transform-to",
+            "sa_flags_required",
+            "sa-flags-required",
+            "sa_flags_forbidden",
+            "sa-flags-forbidden",
+            "state_required",
+            "state-required",
+            "state_forbidden",
+            "state-forbidden",
+        ],
+        BlockContext::Capability => &["type", "allows", "without", "condition", "priority", "desc"],
+        BlockContext::SideEffect => &["target", "desc", "condition", "reversible"],
+        BlockContext::StateTransition => &["object", "from", "to", "condition", "desc"],
+        BlockContext::Constraint => &["desc", "expr"],
+        BlockContext::Lock => &[
+            "type",
+            "scope",
+            "acquired",
+            "released",
+            "held-on-entry",
+            "held-on-exit",
+            "desc",
+        ],
+        BlockContext::Return => &[
+            "type",
+            "check-type",
+            "success",
+            "success-range",
+            "error-values",
+            "desc",
+        ],
+        BlockContext::None => &[],
+    }
+}
+
+/// True if `line` starts with one of `keys` followed by ':'.
+fn starts_subfield(line: &str, keys: &[&str]) -> bool {
+    line.split_once(':')
+        .is_some_and(|(key, _)| keys.contains(&key.trim()))
+}
+
+fn is_indented_line(line: &str) -> bool {
+    line.starts_with("  ") || line.starts_with('\t')
+}
+
+/// Fold free-form prose into paragraphs. Blank lines separate
+/// paragraphs ("\n\n"); wrapped lines inside a paragraph are joined
+/// with spaces, except that a line starting with "- " always begins a
+/// new line so bullet lists survive. Mirrors `_fold_paragraphs()` in
+/// kdoc_apispec.py.
+fn fold_paragraphs(lines: &[&str]) -> String {
+    let mut paragraphs: Vec<Vec<String>> = Vec::new();
+    let mut current: Vec<String> = Vec::new();
+    for line in lines {
+        let line = line.trim();
+        if line.is_empty() {
+            if !current.is_empty() {
+                paragraphs.push(std::mem::take(&mut current));
+            }
+        } else if line.starts_with("- ") || current.is_empty() {
+            current.push(line.to_string());
+        } else if let Some(last) = current.last_mut() {
+            last.push(' ');
+            last.push_str(line);
+        }
+    }
+    if !current.is_empty() {
+        paragraphs.push(current);
+    }
+    paragraphs
+        .iter()
+        .map(|p| p.join("\n"))
+        .collect::<Vec<_>>()
+        .join("\n\n")
+        .replace('\t', " ")
+}
+
+fn expand_tabs(line: &str) -> String {
+    let mut out = String::with_capacity(line.len());
+    let mut col = 0;
+    for c in line.chars() {
+        if c == '\t' {
+            let pad = 8 - col % 8;
+            out.extend(std::iter::repeat_n(' ', pad));
+            col += pad;
+        } else {
+            out.push(c);
+            col += 1;
+        }
+    }
+    out
+}
+
+/// Keep every line of a block on its own line. The indentation shared
+/// by the continuation lines is removed so relative indentation
+/// (nested code) is preserved; runs of blank lines collapse into one.
+/// Mirrors `_fold_lines()` in kdoc_apispec.py.
+fn fold_lines(lines: &[&str]) -> String {
+    let mut lines: Vec<String> = lines
+        .iter()
+        .map(|l| expand_tabs(l).trim_end().to_string())
+        .collect();
+    while lines.first().is_some_and(|l| l.is_empty()) {
+        lines.remove(0);
+    }
+    while lines.last().is_some_and(|l| l.is_empty()) {
+        lines.pop();
+    }
+
+    let indent = |l: &str| l.len() - l.trim_start().len();
+    let base = lines
+        .iter()
+        .skip(1)
+        .filter(|l| !l.is_empty())
+        .map(|l| indent(l))
+        .min()
+        .unwrap_or(0);
+
+    let mut out: Vec<&str> = Vec::new();
+    for line in &lines {
+        if line.is_empty() {
+            if out.last().is_some_and(|l| l.is_empty()) {
+                continue;
+            }
+            out.push("");
+        } else {
+            out.push(&line[base.min(indent(line))..]);
+        }
+    }
+    out.join("\n")
+}
+
+impl KerneldocParser {
+    pub fn new() -> Self {
+        KerneldocParser
+    }
+
+    pub fn parse_kerneldoc(
+        &self,
+        doc: &str,
+        name: &str,
+        api_type: &str,
+        signature: Option<&str>,
+    ) -> Result<ApiSpec> {
+        let mut spec = ApiSpec {
+            name: name.to_string(),
+            api_type: api_type.to_string(),
+            ..Default::default()
+        };
+
+        let lines: Vec<&str> = doc.lines().collect();
+
+        // Extract main description from function name line
+        if let Some(first_line) = lines.first() {
+            if let Some((_, desc)) = first_line.split_once(" - ") {
+                spec.description = Some(desc.trim().to_string());
+            }
+        }
+
+        // Extract type names from SYSCALL_DEFINE signature
+        let type_map = if let Some(sig) = signature {
+            self.extract_types_from_signature(sig)
+        } else {
+            HashMap::new()
+        };
+
+        // Keep track of parameters we've seen (from @param lines)
+        let mut param_map: HashMap<String, ParamSpec> = HashMap::new();
+
+        // Current block being parsed
+        let mut block = BlockContext::None;
+
+        // Temporary storage for current block items
+        let mut current_lock: Option<LockSpec> = None;
+        let mut current_signal: Option<SignalSpec> = None;
+        // Pending symbolic `transform-to:` token. Captured when the parser
+        // sees a non-numeric value, but only reported if the final
+        // `transform_to` after all lines in the signal block is still
+        // unresolved. A later numeric `transform-to:` clears this so we
+        // don't warn about a value that was subsequently overridden.
+        let mut pending_transform_warning: Option<String> = None;
+        let mut current_capability: Option<CapabilitySpec> = None;
+        let mut current_side_effect: Option<SideEffectSpec> = None;
+        let mut current_constraint: Option<ConstraintSpec> = None;
+        let mut current_error: Option<ErrorSpec> = None;
+        let mut current_return: Option<ReturnSpec> = None;
+        let mut current_state_trans: Option<StateTransitionSpec> = None;
+
+        let mut i = 0;
+
+        while i < lines.len() {
+            let line = lines[i];
+            let trimmed = line.trim();
+
+            // Skip empty lines
+            if trimmed.is_empty() {
+                i += 1;
+                continue;
+            }
+
+            // Check if this is an indented continuation line (part of current block)
+            let is_indented = is_indented_line(line);
+
+            // If indented and we're in a block, parse as block attribute.
+            // A subfield line absorbs the lines that follow it until the
+            // next known subfield key, so a value such as
+            // `constraint-type: mask(FOO | BAR |` ... `| BAZ)` or a
+            // wrapped `condition:` arrives as a single logical line.
+            if is_indented && block != BlockContext::None {
+                let keys = block_subfield_keys(&block);
+                let mut logical = trimmed.to_string();
+                if starts_subfield(trimmed, keys) {
+                    let mut j = i + 1;
+                    while j < lines.len() {
+                        let next = lines[j];
+                        let next_trim = next.trim();
+                        if next_trim.is_empty() {
+                            j += 1;
+                            continue;
+                        }
+                        if !is_indented_line(next) || starts_subfield(next_trim, keys) {
+                            break;
+                        }
+                        logical.push(' ');
+                        logical.push_str(next_trim);
+                        i = j;
+                        j += 1;
+                    }
+                }
+                self.parse_block_attribute(
+                    &logical,
+                    &block,
+                    &mut param_map,
+                    &mut current_error,
+                    &mut current_signal,
+                    &mut pending_transform_warning,
+                    &mut current_capability,
+                    &mut current_side_effect,
+                    &mut current_constraint,
+                    &mut current_lock,
+                    &mut current_return,
+                    &mut current_state_trans,
+                );
+                i += 1;
+                continue;
+            }
+
+            // Not indented or not in block: flush current block if any.
+            // If a symbolic `transform-to:` was captured and no later
+            // numeric line cleared it, surface the warning now; by
+            // construction `transform_to` is None in that case.
+            if matches!(block, BlockContext::Signal) {
+                if let Some(raw) = pending_transform_warning.take() {
+                    eprintln!(
+                        "kapi: warning: transform-to: {raw:?} is symbolic; \
+                         source-mode cannot resolve signal numbers portably. \
+                         Use --vmlinux or --debugfs to get the resolved value.",
+                    );
+                }
+            }
+            self.flush_block(
+                &mut block,
+                &mut spec,
+                &mut current_error,
+                &mut current_signal,
+                &mut current_capability,
+                &mut current_side_effect,
+                &mut current_constraint,
+                &mut current_lock,
+                &mut current_return,
+                &mut current_state_trans,
+            );
+
+            // Parse top-level annotations
+            if let Some(rest) = trimmed.strip_prefix("@") {
+                // @param: description (standard kerneldoc parameter)
+                if let Some((param_name, desc)) = rest.split_once(':') {
+                    let param_name = param_name.trim();
+                    let desc = desc.trim();
+                    if !param_name.contains('-') {
+                        let idx = param_map.len() as u32;
+                        let type_name = type_map.get(param_name).cloned().unwrap_or_default();
+                        param_map.insert(
+                            param_name.to_string(),
+                            ParamSpec {
+                                index: idx,
+                                name: param_name.to_string(),
+                                type_name,
+                                description: desc.to_string(),
+                                flags: 0,
+                                param_type: 0,
+                                constraint_type: 0,
+                                constraint: None,
+                                min_value: None,
+                                max_value: None,
+                                valid_mask: None,
+                                enum_values: vec![],
+                                size: None,
+                                alignment: None,
+                                size_param_idx: None,
+                            },
+                        );
+                    }
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("long-desc:") {
+                let (section, next_i) = self.collect_section(&lines, i, rest);
+                let val = fold_paragraphs(&section);
+                spec.long_description = Some(val).filter(|v| !v.is_empty());
+                i = next_i;
+                continue;
+            } else if let Some(rest) = trimmed.strip_prefix("context-flags:") {
+                spec.context_flags = self.parse_context_flags(rest.trim());
+            } else if let Some(rest) = trimmed.strip_prefix("contexts:") {
+                // Short form: "contexts: process, sleepable"
+                spec.context_flags = self.parse_context_list(rest.trim());
+            } else if let Some(rest) = trimmed.strip_prefix("param-count:") {
+                spec.param_count = rest.trim().parse().ok();
+            }
+            // Block-start annotations
+            else if let Some(rest) = trimmed.strip_prefix("param:") {
+                let param_name = rest.trim().to_string();
+                block = BlockContext::Param(param_name.clone());
+                // Ensure param exists in map
+                if !param_map.contains_key(&param_name) {
+                    let idx = param_map.len() as u32;
+                    let type_name = type_map
+                        .get(param_name.as_str())
+                        .cloned()
+                        .unwrap_or_default();
+                    param_map.insert(
+                        param_name.clone(),
+                        ParamSpec {
+                            index: idx,
+                            name: param_name,
+                            type_name,
+                            description: String::new(),
+                            flags: 0,
+                            param_type: 0,
+                            constraint_type: 0,
+                            constraint: None,
+                            min_value: None,
+                            max_value: None,
+                            valid_mask: None,
+                            enum_values: vec![],
+                            size: None,
+                            alignment: None,
+                            size_param_idx: None,
+                        },
+                    );
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("error:") {
+                // error: NAME, condition
+                let parts: Vec<&str> = rest.splitn(2, ',').map(|s| s.trim()).collect();
+                if !parts.is_empty() {
+                    let error_name = parts[0].to_string();
+                    let condition = if parts.len() >= 2 {
+                        parts[1].to_string()
+                    } else {
+                        String::new()
+                    };
+                    let error_code = self.error_name_to_code(&error_name);
+                    current_error = Some(ErrorSpec {
+                        error_code,
+                        name: error_name.clone(),
+                        condition,
+                        description: String::new(),
+                    });
+                    block = BlockContext::Error(error_name);
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("signal:") {
+                let signal_name = rest.trim().to_string();
+                current_signal = Some(SignalSpec {
+                    signal_num: 0,
+                    signal_name,
+                    direction: 1,
+                    action: 0,
+                    target: None,
+                    condition: None,
+                    description: None,
+                    restartable: false,
+                    timing: 0,
+                    priority: 0,
+                    interruptible: false,
+                    queue: None,
+                    sa_flags: 0,
+                    sa_flags_required: 0,
+                    sa_flags_forbidden: 0,
+                    state_required: 0,
+                    state_forbidden: 0,
+                    error_on_signal: None,
+                    transform_to: None,
+                });
+                block = BlockContext::Signal;
+            } else if let Some(rest) = trimmed.strip_prefix("capability:") {
+                let parts: Vec<&str> = rest.split(',').map(|s| s.trim()).collect();
+                if !parts.is_empty() {
+                    let cap_name = parts[0].to_string();
+                    let cap_value = self.parse_capability_value(&cap_name);
+                    // If we have 3 parts, it's flat format: capability: CAP, action, name
+                    let (action, name) = if parts.len() >= 3 {
+                        (parts[1].to_string(), parts[2].to_string())
+                    } else {
+                        (String::new(), cap_name.clone())
+                    };
+                    current_capability = Some(CapabilitySpec {
+                        capability: cap_value,
+                        name,
+                        action,
+                        allows: String::new(),
+                        without_cap: String::new(),
+                        check_condition: None,
+                        priority: Some(0),
+                        alternatives: vec![],
+                    });
+                    block = BlockContext::Capability;
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("side-effect:") {
+                // Could be flat format (comma-separated) or block start
+                let rest = rest.trim();
+                // Check if it's the flat format with commas
+                let comma_parts: Vec<&str> = rest.splitn(3, ',').map(|s| s.trim()).collect();
+                if comma_parts.len() >= 3 {
+                    // Flat format: side-effect: TYPE, target, desc
+                    let mut effect = SideEffectSpec {
+                        effect_type: self.parse_effect_type(comma_parts[0]),
+                        target: comma_parts[1].to_string(),
+                        condition: None,
+                        description: comma_parts[2].to_string(),
+                        reversible: false,
+                    };
+                    if comma_parts[2].contains("reversible=yes") {
+                        effect.reversible = true;
+                    }
+                    spec.side_effects.push(effect);
+                } else {
+                    // Block format: side-effect: TYPE
+                    current_side_effect = Some(SideEffectSpec {
+                        effect_type: self.parse_effect_type(rest),
+                        target: String::new(),
+                        condition: None,
+                        description: String::new(),
+                        reversible: false,
+                    });
+                    block = BlockContext::SideEffect;
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("state-trans:") {
+                // Flat form: state-trans: OBJECT, FROM, TO, DESCRIPTION
+                // (the description may itself contain commas). Block
+                // form: a bare OBJECT followed by from:/to:/condition:/
+                // desc: subfields.
+                if rest.contains(',') {
+                    let mut parts = rest.splitn(4, ',').map(|s| s.trim());
+                    let mut next_part = || parts.next().unwrap_or_default().to_string();
+                    spec.state_transitions.push(StateTransitionSpec {
+                        object: next_part(),
+                        from_state: next_part(),
+                        to_state: next_part(),
+                        condition: None,
+                        description: next_part(),
+                    });
+                } else {
+                    current_state_trans = Some(StateTransitionSpec {
+                        object: rest.trim().to_string(),
+                        from_state: String::new(),
+                        to_state: String::new(),
+                        condition: None,
+                        description: String::new(),
+                    });
+                }
+                block = BlockContext::StateTransition;
+            } else if let Some(rest) = trimmed.strip_prefix("constraint:") {
+                let rest = rest.trim();
+                // Could be flat format: constraint: name, desc
+                // Or block format: constraint: name
+                let parts: Vec<&str> = rest.splitn(2, ',').map(|s| s.trim()).collect();
+                if parts.len() >= 2 {
+                    // Flat format
+                    current_constraint = Some(ConstraintSpec {
+                        name: parts[0].to_string(),
+                        description: parts[1].to_string(),
+                        expression: None,
+                    });
+                } else {
+                    // Block format
+                    current_constraint = Some(ConstraintSpec {
+                        name: rest.to_string(),
+                        description: String::new(),
+                        expression: None,
+                    });
+                }
+                block = BlockContext::Constraint;
+            } else if let Some(rest) = trimmed.strip_prefix("lock:") {
+                let rest = rest.trim();
+                // Could be flat: lock: name, type
+                // Or block: lock: name
+                let parts: Vec<&str> = rest.split(',').map(|s| s.trim()).collect();
+                if parts.len() >= 2 {
+                    current_lock = Some(LockSpec {
+                        lock_name: parts[0].to_string(),
+                        lock_type: self.parse_lock_type(parts[1]),
+                        scope: super::KAPI_LOCK_INTERNAL,
+                        description: String::new(),
+                    });
+                } else {
+                    current_lock = Some(LockSpec {
+                        lock_name: rest.to_string(),
+                        lock_type: 0,
+                        scope: super::KAPI_LOCK_INTERNAL,
+                        description: String::new(),
+                    });
+                }
+                block = BlockContext::Lock;
+            }
+            // Other top-level annotations
+            else if let Some(rest) = trimmed.strip_prefix("return:") {
+                let rest = rest.trim();
+                if rest.is_empty() {
+                    // Block format
+                    current_return = Some(ReturnSpec {
+                        type_name: String::new(),
+                        description: String::new(),
+                        return_type: 0,
+                        check_type: 0,
+                        success_value: None,
+                        success_min: None,
+                        success_max: None,
+                        error_values: vec![],
+                    });
+                    block = BlockContext::Return;
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("examples:") {
+                let (section, next_i) = self.collect_section(&lines, i, rest);
+                let val = fold_lines(&section);
+                spec.examples = Some(val).filter(|v| !v.is_empty());
+                i = next_i;
+                continue;
+            } else if let Some(rest) = trimmed.strip_prefix("notes:") {
+                let (section, next_i) = self.collect_section(&lines, i, rest);
+                let val = fold_paragraphs(&section);
+                spec.notes = Some(val).filter(|v| !v.is_empty());
+                i = next_i;
+                continue;
+            }
+
+            i += 1;
+        }
+
+        // Flush any remaining block. Emit a pending symbolic
+        // `transform-to:` warning if the final state still has no
+        // resolved numeric value (see per-line loop for rationale).
+        if matches!(block, BlockContext::Signal) {
+            if let Some(raw) = pending_transform_warning.take() {
+                eprintln!(
+                    "kapi: warning: transform-to: {raw:?} is symbolic; \
+                     source-mode cannot resolve signal numbers portably. \
+                     Use --vmlinux or --debugfs to get the resolved value.",
+                );
+            }
+        }
+        self.flush_block(
+            &mut block,
+            &mut spec,
+            &mut current_error,
+            &mut current_signal,
+            &mut current_capability,
+            &mut current_side_effect,
+            &mut current_constraint,
+            &mut current_lock,
+            &mut current_return,
+            &mut current_state_trans,
+        );
+
+        // Convert param_map to vec preserving order
+        let mut params: Vec<ParamSpec> = param_map.into_values().collect();
+        params.sort_by_key(|p| p.index);
+
+        // If the spec carries an explicit param-count, warn when it
+        // disagrees with the number of param: blocks we actually saw.
+        if let Some(claimed) = spec.param_count {
+            if claimed as usize != params.len() {
+                eprintln!(
+                    "kapi: {}: param-count: {} disagrees with {} param: block(s)",
+                    name,
+                    claimed,
+                    params.len(),
+                );
+            }
+        }
+
+        for param in &mut params {
+            param.drop_unset_string_limits();
+        }
+        spec.parameters = params;
+
+        Ok(spec)
+    }
+
+    /// Parse an indented attribute line within a block
+    #[allow(clippy::too_many_arguments)]
+    fn parse_block_attribute(
+        &self,
+        trimmed: &str,
+        block: &BlockContext,
+        param_map: &mut HashMap<String, ParamSpec>,
+        current_error: &mut Option<ErrorSpec>,
+        current_signal: &mut Option<SignalSpec>,
+        pending_transform_warning: &mut Option<String>,
+        current_capability: &mut Option<CapabilitySpec>,
+        current_side_effect: &mut Option<SideEffectSpec>,
+        current_constraint: &mut Option<ConstraintSpec>,
+        current_lock: &mut Option<LockSpec>,
+        current_return: &mut Option<ReturnSpec>,
+        current_state_trans: &mut Option<StateTransitionSpec>,
+    ) {
+        match block {
+            BlockContext::Param(param_name) => {
+                if let Some(param) = param_map.get_mut(param_name) {
+                    if let Some(rest) = trimmed.strip_prefix("type:") {
+                        // Accept either:
+                        //   type: KAPI_TYPE_UINT              (long, single token)
+                        //   type: uint                        (short, single token)
+                        //   type: uint, input                 (short, type + flags)
+                        //   type: path, input                 (short, type + flags)
+                        // Single-token inputs leave flags alone; they are
+                        // set by a separate `flags:` line.
+                        //
+                        // User-space pointer types (user_ptr, path) imply
+                        // KAPI_PARAM_USER, so specs don't need to repeat
+                        // `user` after the type.
+                        let mut parts = rest.split(',').map(str::trim);
+                        let type_token = parts.next();
+                        if let Some(ty) = type_token {
+                            param.param_type = self.parse_param_type(ty);
+                        }
+                        for flag in parts {
+                            param.flags |= self.parse_param_flag_token(flag);
+                        }
+                        if type_token.map(type_implies_user_flag).unwrap_or(false) {
+                            param.flags |= 1 << 6; // KAPI_PARAM_USER
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("flags:") {
+                        param.flags = self.parse_param_flags(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("constraint-type:") {
+                        // Accepts `KAPI_CONSTRAINT_*` enum tokens or
+                        // function-call expressions like `range(0, 4096)`
+                        // / `mask(0xff)` / `buffer(2)` that also populate
+                        // the matching numeric fields on `param`.
+                        let text = rest.trim();
+                        if !self.apply_constraint_expr(param, text) {
+                            param.constraint_type = self.parse_constraint_type(text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("valid-mask:") {
+                        // Symbolic mask values need cpp-level resolution;
+                        // leave that to the binary reader.
+                        let _ = rest;
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("cdesc:")
+                        .or_else(|| trimmed.strip_prefix("constraint:"))
+                    {
+                        // Free-text constraint description.
+                        param.constraint = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("range:") {
+                        let parts: Vec<&str> = rest.split(',').map(|s| s.trim()).collect();
+                        if parts.len() >= 2 {
+                            param.min_value = parts[0].parse().ok();
+                            param.max_value = parts[1].parse().ok();
+                            param.constraint_type = 1; // KAPI_CONSTRAINT_RANGE
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("size-param:") {
+                        param.size_param_idx = rest.trim().parse().ok();
+                    } else if let Some(rest) = trimmed.strip_prefix("description:") {
+                        param.description = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        param.description = rest.trim().to_string();
+                    } else if !trimmed.contains(':') || trimmed.starts_with("  ") {
+                        // Continuation of the previous attribute's value.
+                        if let Some(c) = param.constraint.as_mut() {
+                            c.push(' ');
+                            c.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Error(_) => {
+                if let Some(error) = current_error.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if error.description.is_empty() {
+                            error.description = text;
+                        } else {
+                            error.description.push(' ');
+                            error.description.push_str(&text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("condition:") {
+                        error.condition = rest.trim().to_string();
+                    } else {
+                        // Continuation of description
+                        if !error.description.is_empty() {
+                            error.description.push(' ');
+                            error.description.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Signal => {
+                if let Some(signal) = current_signal.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("direction:") {
+                        signal.direction = self.parse_signal_direction(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("action:") {
+                        signal.action = self.parse_signal_action(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("condition:") {
+                        signal.condition = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if signal.description.is_none() {
+                            signal.description = Some(text);
+                        } else if let Some(d) = signal.description.as_mut() {
+                            d.push(' ');
+                            d.push_str(&text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("errno:") {
+                        // `error:` cannot be used here because kerneldoc
+                        // promotes it to a top-level section header.
+                        //
+                        // Accepted forms:
+                        //   errno: -4         -> numeric literal, stored as-is
+                        //   errno: -EINTR     -> kernel convention; resolve
+                        //                       the symbol and negate
+                        //   errno: EINTR      -> bare symbol; resolved value
+                        //                       is already negative
+                        let value = rest.trim();
+                        signal.error_on_signal = if let Ok(code) = value.parse::<i32>() {
+                            Some(code)
+                        } else if let Some(name) = value.strip_prefix('-') {
+                            // `error_name_to_code` already returns the negated
+                            // code (e.g. "EINTR" -> -4), so `-EINTR` resolves
+                            // to -4 too; the leading `-` on the symbolic form
+                            // is kernel-source convention, not a second negation.
+                            Some(self.error_name_to_code(name))
+                        } else {
+                            Some(self.error_name_to_code(value))
+                        };
+                    } else if let Some(rest) = trimmed.strip_prefix("timing:") {
+                        signal.timing = self.parse_signal_timing(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("restartable:") {
+                        let val = rest.trim().to_lowercase();
+                        signal.restartable = matches!(val.as_str(), "yes" | "true" | "1");
+                    } else if let Some(rest) = trimmed.strip_prefix("interruptible:") {
+                        let val = rest.trim().to_lowercase();
+                        signal.interruptible = matches!(val.as_str(), "yes" | "true" | "1");
+                    } else if let Some(rest) = trimmed.strip_prefix("priority:") {
+                        signal.priority = rest.trim().parse().unwrap_or(0);
+                    } else if let Some(rest) = trimmed.strip_prefix("target:") {
+                        signal.target = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("queue:")
+                        .or_else(|| trimmed.strip_prefix("queue_behavior:"))
+                    {
+                        signal.queue = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("number:") {
+                        signal.signal_num = rest.trim().parse().unwrap_or(0);
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("transform-to:")
+                        .or_else(|| trimmed.strip_prefix("transform_to:"))
+                        .or_else(|| trimmed.strip_prefix("transform:"))
+                    {
+                        // transform-to: takes a signal constant (e.g.
+                        // SIGKILL) or a numeric literal. Only a numeric
+                        // literal fills `transform_to`; symbolic values
+                        // cannot be resolved portably in userspace
+                        // because signal numbers are arch-dependent and
+                        // we have no access to the target arch's
+                        // <asm/signal.h>. Report such cases to stderr so
+                        // they are not silently lost, and point the user
+                        // at --vmlinux / --debugfs, which consult the
+                        // compiled struct where the C preprocessor has
+                        // already baked in the correct value.
+                        //
+                        // Assign unconditionally so the last line in
+                        // the kerneldoc wins and an intended symbolic
+                        // override doesn't silently leave a stale
+                        // numeric value from an earlier line. The
+                        // warning is deferred until flush_block() so a
+                        // subsequent numeric line can cancel it; if the
+                        // last line was still symbolic we report it
+                        // then.
+                        let v = rest.trim();
+                        let parsed = v.parse::<i32>().ok();
+                        signal.transform_to = parsed;
+                        if parsed.is_some() {
+                            *pending_transform_warning = None;
+                        } else if !v.is_empty() {
+                            *pending_transform_warning = Some(v.to_string());
+                        }
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("sa-flags-required:")
+                        .or_else(|| trimmed.strip_prefix("sa_flags_required:"))
+                    {
+                        signal.sa_flags_required = self.parse_hex_or_bitmask(rest.trim());
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("sa-flags-forbidden:")
+                        .or_else(|| trimmed.strip_prefix("sa_flags_forbidden:"))
+                    {
+                        signal.sa_flags_forbidden = self.parse_hex_or_bitmask(rest.trim());
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("state-required:")
+                        .or_else(|| trimmed.strip_prefix("state_required:"))
+                    {
+                        signal.state_required = self.parse_signal_state_mask(rest.trim());
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("state-forbidden:")
+                        .or_else(|| trimmed.strip_prefix("state_forbidden:"))
+                    {
+                        signal.state_forbidden = self.parse_signal_state_mask(rest.trim());
+                    } else {
+                        // Continuation of description
+                        if let Some(d) = signal.description.as_mut() {
+                            d.push(' ');
+                            d.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Capability => {
+                if let Some(cap) = current_capability.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("type:") {
+                        cap.action = canon_kapi_cap_action(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("allows:") {
+                        cap.allows = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("without:") {
+                        cap.without_cap = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("condition:") {
+                        cap.check_condition = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("priority:") {
+                        cap.priority = rest.trim().parse().ok();
+                    }
+                }
+            }
+            BlockContext::SideEffect => {
+                if let Some(effect) = current_side_effect.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("target:") {
+                        effect.target = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("condition:") {
+                        effect.condition = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if effect.description.is_empty() {
+                            effect.description = text;
+                        } else {
+                            effect.description.push(' ');
+                            effect.description.push_str(&text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("reversible:") {
+                        let val = rest.trim().to_lowercase();
+                        effect.reversible = matches!(val.as_str(), "yes" | "true" | "1");
+                    } else {
+                        // Continuation of description
+                        if !effect.description.is_empty() {
+                            effect.description.push(' ');
+                            effect.description.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Constraint => {
+                if let Some(constraint) = current_constraint.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if constraint.description.is_empty() {
+                            constraint.description = text;
+                        } else {
+                            constraint.description.push(' ');
+                            constraint.description.push_str(&text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("expr:") {
+                        constraint.expression = Some(rest.trim().to_string());
+                    } else {
+                        // Continuation of description
+                        if !constraint.description.is_empty() {
+                            constraint.description.push(' ');
+                            constraint.description.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Lock => {
+                if let Some(lock) = current_lock.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("type:") {
+                        lock.lock_type = self.parse_lock_type(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("scope:") {
+                        lock.scope = match rest.trim() {
+                            "internal" => super::KAPI_LOCK_INTERNAL,
+                            "acquires" => super::KAPI_LOCK_ACQUIRES,
+                            "releases" => super::KAPI_LOCK_RELEASES,
+                            "caller_held" => super::KAPI_LOCK_CALLER_HELD,
+                            _ => super::KAPI_LOCK_INTERNAL,
+                        };
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if lock.description.is_empty() {
+                            lock.description = text;
+                        } else {
+                            lock.description.push(' ');
+                            lock.description.push_str(&text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("acquired:") {
+                        // Same rule as kdoc_apispec.py: a lock that is both
+                        // acquired and released stays KAPI_LOCK_INTERNAL.
+                        if matches!(rest.trim().to_lowercase().as_str(), "true" | "yes") {
+                            lock.scope = if lock.scope == super::KAPI_LOCK_RELEASES {
+                                super::KAPI_LOCK_INTERNAL
+                            } else {
+                                super::KAPI_LOCK_ACQUIRES
+                            };
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("released:") {
+                        if matches!(rest.trim().to_lowercase().as_str(), "true" | "yes") {
+                            lock.scope = if lock.scope == super::KAPI_LOCK_ACQUIRES {
+                                super::KAPI_LOCK_INTERNAL
+                            } else {
+                                super::KAPI_LOCK_RELEASES
+                            };
+                        }
+                    } else {
+                        // Continuation of description
+                        if !lock.description.is_empty() {
+                            lock.description.push(' ');
+                            lock.description.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Return => {
+                if let Some(ret) = current_return.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("type:") {
+                        let raw = rest.trim();
+                        ret.type_name = raw.to_string();
+                        ret.return_type = self.parse_param_type(raw);
+                    } else if let Some(rest) = trimmed.strip_prefix("check-type:") {
+                        ret.check_type = self.parse_return_check_type(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("success:") {
+                        (ret.success_value, ret.success_min, ret.success_max) =
+                            parse_return_success(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if ret.description.is_empty() {
+                            ret.description = text;
+                        } else {
+                            ret.description.push(' ');
+                            ret.description.push_str(&text);
+                        }
+                    } else {
+                        // Continuation of description
+                        if !ret.description.is_empty() {
+                            ret.description.push(' ');
+                            ret.description.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::StateTransition => {
+                if let Some(trans) = current_state_trans.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("object:") {
+                        trans.object = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("from:") {
+                        trans.from_state = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("to:") {
+                        trans.to_state = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("condition:") {
+                        trans.condition = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        trans.description = rest.trim().to_string();
+                    }
+                }
+            }
+            BlockContext::None => {}
+        }
+    }
+
+    /// Flush the current block, pushing items into the spec
+    #[allow(clippy::too_many_arguments)]
+    fn flush_block(
+        &self,
+        block: &mut BlockContext,
+        spec: &mut ApiSpec,
+        current_error: &mut Option<ErrorSpec>,
+        current_signal: &mut Option<SignalSpec>,
+        current_capability: &mut Option<CapabilitySpec>,
+        current_side_effect: &mut Option<SideEffectSpec>,
+        current_constraint: &mut Option<ConstraintSpec>,
+        current_lock: &mut Option<LockSpec>,
+        current_return: &mut Option<ReturnSpec>,
+        current_state_trans: &mut Option<StateTransitionSpec>,
+    ) {
+        match block {
+            BlockContext::Error(_) => {
+                if let Some(error) = current_error.take() {
+                    spec.errors.push(error);
+                }
+            }
+            BlockContext::Signal => {
+                if let Some(signal) = current_signal.take() {
+                    spec.signals.push(signal);
+                }
+            }
+            BlockContext::Capability => {
+                if let Some(cap) = current_capability.take() {
+                    spec.capabilities.push(cap);
+                }
+            }
+            BlockContext::SideEffect => {
+                if let Some(effect) = current_side_effect.take() {
+                    spec.side_effects.push(effect);
+                }
+            }
+            BlockContext::Constraint => {
+                if let Some(constraint) = current_constraint.take() {
+                    spec.constraints.push(constraint);
+                }
+            }
+            BlockContext::Lock => {
+                if let Some(lock) = current_lock.take() {
+                    spec.locks.push(lock);
+                }
+            }
+            BlockContext::Return => {
+                if let Some(mut ret) = current_return.take() {
+                    ret.keep_used_success_fields();
+                    spec.return_spec = Some(ret);
+                }
+            }
+            BlockContext::StateTransition => {
+                if let Some(trans) = current_state_trans.take() {
+                    spec.state_transitions.push(trans);
+                }
+            }
+            _ => {}
+        }
+        *block = BlockContext::None;
+    }
+
+    /// Extract parameter type names from SYSCALL_DEFINE signature
+    fn extract_types_from_signature(&self, sig: &str) -> HashMap<String, String> {
+        let mut types = HashMap::new();
+
+        // Find content between outermost parens
+        let content = if let Some(start) = sig.find('(') {
+            let end = sig.rfind(')').unwrap_or(sig.len());
+            &sig[start + 1..end]
+        } else {
+            return types;
+        };
+
+        // Split by comma and process type/name pairs
+        // SYSCALL_DEFINE format: (syscall_name, type1, name1, type2, name2, ...)
+        let parts: Vec<&str> = content.split(',').map(|s| s.trim()).collect();
+
+        // Skip first part (syscall name), then process pairs
+        let mut i = 1;
+        while i + 1 < parts.len() {
+            let type_part = parts[i].trim();
+            let name_part = parts[i + 1].trim();
+
+            // Build the type_name string: "type name"
+            let type_name = format!("{} {}", type_part, name_part);
+            types.insert(name_part.to_string(), type_name);
+
+            i += 2;
+        }
+
+        types
+    }
+
+    /// Collect the raw lines of a free-form section: the text after the
+    /// key plus every following indented or blank line, up to the next
+    /// annotation line.
+    fn collect_section<'a>(
+        &self,
+        lines: &[&'a str],
+        start_idx: usize,
+        first_part: &'a str,
+    ) -> (Vec<&'a str>, usize) {
+        let mut section = vec![first_part];
+        let mut i = start_idx + 1;
+
+        while i < lines.len() {
+            let line = lines[i];
+            if self.is_annotation_line(line) {
+                break;
+            }
+            if !line.trim().is_empty() && !is_indented_line(line) {
+                break;
+            }
+            section.push(line);
+            i += 1;
+        }
+
+        (section, i)
+    }
+
+    fn is_annotation_line(&self, line: &str) -> bool {
+        let trimmed = line.trim_start();
+        if !trimmed.contains(':') {
+            return false;
+        }
+        let annotations = [
+            "param:",
+            "param-count:",
+            "error:",
+            "lock:",
+            "signal:",
+            "side-effect:",
+            "state-trans:",
+            "capability:",
+            "constraint:",
+            "return:",
+            "examples:",
+            "notes:",
+            "context-",
+            "long-desc:",
+            "api-type:",
+        ];
+
+        for ann in &annotations {
+            if trimmed.starts_with(ann) {
+                return true;
+            }
+        }
+        false
+    }
+
+    /// Parse a constraint expression and apply it to `param`.
+    /// Shapes:
+    ///   NAME                         (e.g. "user_path", "nonzero")
+    ///   NAME ( ARG (, ARG)* )        (e.g. "range(0, 4096)", "buffer(2)")
+    /// Returns true if the expression matched a known constraint kind,
+    /// populating `param`'s numeric fields. Returns false if the text
+    /// is free-form, leaving `param` untouched.
+    fn apply_constraint_expr(&self, param: &mut ParamSpec, text: &str) -> bool {
+        let t = text.trim();
+        if t.is_empty() {
+            return false;
+        }
+        // Split NAME ( ARGS ); no nesting, no escaping.
+        let (name, args): (&str, Option<&str>) = match (t.find('('), t.rfind(')')) {
+            (Some(lp), Some(rp)) if rp > lp => (t[..lp].trim(), Some(t[lp + 1..rp].trim())),
+            _ => (t, None),
+        };
+        // Bail out on anything that looks like free text (spaces inside the
+        // name part).
+        if name.contains(char::is_whitespace) || name.is_empty() {
+            return false;
+        }
+        let name_lc = name.to_ascii_lowercase();
+        let split_args = || -> Vec<String> {
+            args.map(|a| a.split(',').map(|s| s.trim().to_string()).collect())
+                .unwrap_or_default()
+        };
+        match name_lc.as_str() {
+            "range" => {
+                let a = split_args();
+                if a.len() != 2 {
+                    return false;
+                }
+                param.min_value = parse_i64_literal(&a[0]);
+                param.max_value = parse_i64_literal(&a[1]);
+                param.constraint_type = 1; // KAPI_CONSTRAINT_RANGE
+                true
+            }
+            "mask" => {
+                let a = split_args();
+                if a.len() != 1 {
+                    return false;
+                }
+                // Symbolic masks (e.g. "O_RDONLY | O_WRONLY | ...") can't
+                // be resolved at parse time; leave valid_mask as None so
+                // downstream consumers treat the mask as unknown, matching
+                // the long-form `valid-mask:` handler (which also leaves
+                // the slot untouched when the value isn't a literal).
+                param.valid_mask = parse_u64_literal(&a[0]);
+                param.constraint_type = 2; // KAPI_CONSTRAINT_MASK
+                true
+            }
+            "enum" => {
+                let a = split_args();
+                if a.is_empty() {
+                    return false;
+                }
+                // --vmlinux and --debugfs report each value in decimal, so
+                // numeric literals are normalised; symbolic names stay.
+                param.enum_values = a
+                    .into_iter()
+                    .map(|v| parse_i64_literal(&v).map_or(v, |n| n.to_string()))
+                    .collect();
+                param.constraint_type = 3; // KAPI_CONSTRAINT_ENUM
+                true
+            }
+            "alignment" | "align" => {
+                let a = split_args();
+                if a.len() != 1 {
+                    return false;
+                }
+                param.alignment = parse_u64_literal(&a[0]).and_then(|n| u32::try_from(n).ok());
+                param.constraint_type = 4; // KAPI_CONSTRAINT_ALIGNMENT
+                true
+            }
+            "power_of_two" => {
+                if args.is_some() {
+                    return false;
+                }
+                param.constraint_type = 5; // KAPI_CONSTRAINT_POWER_OF_TWO
+                true
+            }
+            "page_aligned" => {
+                if args.is_some() {
+                    return false;
+                }
+                param.constraint_type = 6; // KAPI_CONSTRAINT_PAGE_ALIGNED
+                true
+            }
+            "nonzero" => {
+                if args.is_some() {
+                    return false;
+                }
+                param.constraint_type = 7; // KAPI_CONSTRAINT_NONZERO
+                true
+            }
+            "user_string" => {
+                // Optional size argument: user_string(N)
+                if let Some(arg) = args {
+                    if let Some(n) = parse_u64_literal(arg).and_then(|n| u32::try_from(n).ok()) {
+                        param.size = Some(n);
+                    }
+                }
+                param.constraint_type = 8; // KAPI_CONSTRAINT_USER_STRING
+                true
+            }
+            "user_path" => {
+                if args.is_some() {
+                    return false;
+                }
+                param.constraint_type = 9; // KAPI_CONSTRAINT_USER_PATH
+                true
+            }
+            "user_ptr" => {
+                if args.is_some() {
+                    return false;
+                }
+                param.constraint_type = 10; // KAPI_CONSTRAINT_USER_PTR
+                true
+            }
+            "buffer" => {
+                // buffer(size_param_idx): capture the index into
+                // param.size_param_idx so it matches the long-form
+                // `size-param: N` handler below (and the C struct
+                // field populated by KAPI_PARAM_SIZE_PARAM()).
+                let a = split_args();
+                if a.len() != 1 {
+                    return false;
+                }
+                param.size_param_idx = a[0].parse().ok();
+                param.constraint_type = 11; // KAPI_CONSTRAINT_BUFFER
+                true
+            }
+            "custom" => {
+                // custom(fn_name): record function name as free-text constraint
+                // so downstream tooling can wire it up.
+                if let Some(arg) = args {
+                    param.constraint = Some(arg.trim().to_string());
+                }
+                param.constraint_type = 12; // KAPI_CONSTRAINT_CUSTOM
+                true
+            }
+            _ => false,
+        }
+    }
+
+    fn parse_context_flags(&self, flags: &str) -> Vec<String> {
+        flags
+            .split('|')
+            .map(|f| self.ctx_alias(f.trim()).to_string())
+            .filter(|f| !f.is_empty())
+            .collect()
+    }
+
+    /// Parse a comma-separated short-form context list
+    /// (e.g. "process, sleepable" -> ["KAPI_CTX_PROCESS", "KAPI_CTX_SLEEPABLE"]).
+    /// Tokens that already look like KAPI_CTX_* are passed through.
+    fn parse_context_list(&self, flags: &str) -> Vec<String> {
+        flags
+            .split(',')
+            .map(|f| self.ctx_alias(f.trim()).to_string())
+            .filter(|f| !f.is_empty())
+            .collect()
+    }
+
+    /// Canonicalise a single context token to its KAPI_CTX_* spelling.
+    /// Short aliases are case-insensitive. Unknown tokens pass through
+    /// verbatim so mixed/long-form input keeps working.
+    fn ctx_alias(&self, tok: &str) -> String {
+        let t = tok.trim();
+        if t.is_empty() {
+            return String::new();
+        }
+        match t.to_ascii_lowercase().as_str() {
+            "process" => "KAPI_CTX_PROCESS".to_string(),
+            "softirq" => "KAPI_CTX_SOFTIRQ".to_string(),
+            "hardirq" => "KAPI_CTX_HARDIRQ".to_string(),
+            "nmi" => "KAPI_CTX_NMI".to_string(),
+            "atomic" => "KAPI_CTX_ATOMIC".to_string(),
+            "sleepable" => "KAPI_CTX_SLEEPABLE".to_string(),
+            "preempt_disabled" => "KAPI_CTX_PREEMPT_DISABLED".to_string(),
+            "irq_disabled" => "KAPI_CTX_IRQ_DISABLED".to_string(),
+            _ => t.to_string(),
+        }
+    }
+
+    fn error_name_to_code(&self, name: &str) -> i32 {
+        match name {
+            "EPERM" => -1,
+            "ENOENT" => -2,
+            "ESRCH" => -3,
+            "EINTR" => -4,
+            "EIO" => -5,
+            "ENXIO" => -6,
+            "E2BIG" => -7,
+            "ENOEXEC" => -8,
+            "EBADF" => -9,
+            "ECHILD" => -10,
+            "EAGAIN" | "EWOULDBLOCK" => -11,
+            "ENOMEM" => -12,
+            "EACCES" => -13,
+            "EFAULT" => -14,
+            "ENOTBLK" => -15,
+            "EBUSY" => -16,
+            "EEXIST" => -17,
+            "EXDEV" => -18,
+            "ENODEV" => -19,
+            "ENOTDIR" => -20,
+            "EISDIR" => -21,
+            "EINVAL" => -22,
+            "ENFILE" => -23,
+            "EMFILE" => -24,
+            "ENOTTY" => -25,
+            "ETXTBSY" => -26,
+            "EFBIG" => -27,
+            "ENOSPC" => -28,
+            "ESPIPE" => -29,
+            "EROFS" => -30,
+            "EMLINK" => -31,
+            "EPIPE" => -32,
+            "EDOM" => -33,
+            "ERANGE" => -34,
+            "EDEADLK" => -35,
+            "ENAMETOOLONG" => -36,
+            "ENOLCK" => -37,
+            "ENOSYS" => -38,
+            "ENOTEMPTY" => -39,
+            "ELOOP" => -40,
+            "ENOMSG" => -42,
+            "ENODATA" => -61,
+            "ENOLINK" => -67,
+            "EPROTO" => -71,
+            "EOVERFLOW" => -75,
+            "ELIBBAD" => -80,
+            "EILSEQ" => -84,
+            "ENOTSOCK" => -88,
+            "EDESTADDRREQ" => -89,
+            "EMSGSIZE" => -90,
+            "EPROTOTYPE" => -91,
+            "ENOPROTOOPT" => -92,
+            "EPROTONOSUPPORT" => -93,
+            "EOPNOTSUPP" | "ENOTSUP" => -95,
+            "EADDRINUSE" => -98,
+            "EADDRNOTAVAIL" => -99,
+            "ENETDOWN" => -100,
+            "ENETUNREACH" => -101,
+            "ENETRESET" => -102,
+            "ECONNABORTED" => -103,
+            "ECONNRESET" => -104,
+            "ENOBUFS" => -105,
+            "EISCONN" => -106,
+            "ENOTCONN" => -107,
+            "ETIMEDOUT" => -110,
+            "ECONNREFUSED" => -111,
+            "EALREADY" => -114,
+            "EINPROGRESS" => -115,
+            "ESTALE" => -116,
+            "EDQUOT" => -122,
+            "ENOMEDIUM" => -123,
+            "ENOKEY" => -126,
+            "EHWPOISON" => -133,
+            "ERESTARTSYS" => -512,
+            _ => 0,
+        }
+    }
+
+    /// Map a KAPI_TYPE_* token (or its short-form alias) to the numeric
+    /// value declared in `enum kapi_param_type` in
+    /// `include/linux/kernel_api_spec.h`.
+    fn parse_param_type(&self, type_str: &str) -> u32 {
+        let s = type_str.trim();
+        match s {
+            "KAPI_TYPE_VOID" => 0,
+            "KAPI_TYPE_INT" => 1,
+            "KAPI_TYPE_UINT" => 2,
+            "KAPI_TYPE_PTR" => 3,
+            "KAPI_TYPE_STRUCT" => 4,
+            "KAPI_TYPE_UNION" => 5,
+            "KAPI_TYPE_ENUM" => 6,
+            "KAPI_TYPE_FUNC_PTR" => 7,
+            "KAPI_TYPE_ARRAY" => 8,
+            "KAPI_TYPE_FD" => 9,
+            "KAPI_TYPE_USER_PTR" => 10,
+            "KAPI_TYPE_PATH" => 11,
+            "KAPI_TYPE_CUSTOM" => 12,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "void" => 0,
+                "int" => 1,
+                "uint" => 2,
+                "ptr" => 3,
+                "struct" => 4,
+                "union" => 5,
+                "enum" => 6,
+                "func_ptr" => 7,
+                "array" => 8,
+                "fd" => 9,
+                "user_ptr" | "uptr" => 10,
+                "path" => 11,
+                "custom" => 12,
+                _ => 0,
+            },
+        }
+    }
+
+    /// Map a KAPI_CONSTRAINT_* token to the numeric value declared in
+    /// `enum kapi_constraint_type` in `include/linux/kernel_api_spec.h`.
+    fn parse_constraint_type(&self, type_str: &str) -> u32 {
+        let s = type_str.trim();
+        match s {
+            "KAPI_CONSTRAINT_NONE" => 0,
+            "KAPI_CONSTRAINT_RANGE" => 1,
+            "KAPI_CONSTRAINT_MASK" => 2,
+            "KAPI_CONSTRAINT_ENUM" => 3,
+            "KAPI_CONSTRAINT_ALIGNMENT" => 4,
+            "KAPI_CONSTRAINT_POWER_OF_TWO" => 5,
+            "KAPI_CONSTRAINT_PAGE_ALIGNED" => 6,
+            "KAPI_CONSTRAINT_NONZERO" => 7,
+            "KAPI_CONSTRAINT_USER_STRING" => 8,
+            "KAPI_CONSTRAINT_USER_PATH" => 9,
+            "KAPI_CONSTRAINT_USER_PTR" => 10,
+            "KAPI_CONSTRAINT_BUFFER" => 11,
+            "KAPI_CONSTRAINT_CUSTOM" => 12,
+            _ => 0,
+        }
+    }
+
+    fn parse_param_flags(&self, flags: &str) -> u32 {
+        flags
+            .split('|')
+            .map(|f| self.parse_param_flag_token(f.trim()))
+            .fold(0, |acc, bit| acc | bit)
+    }
+
+    /// Parse one flag token (long or short form, case-insensitive for
+    /// short form). Returns 0 for unknown tokens.
+    fn parse_param_flag_token(&self, tok: &str) -> u32 {
+        let t = tok.trim();
+        // KAPI_PARAM_* names and upper-case short forms first.
+        match t {
+            "KAPI_PARAM_IN" | "IN" => return 1,
+            "KAPI_PARAM_OUT" | "OUT" => return 2,
+            "KAPI_PARAM_INOUT" | "INOUT" => return 3,
+            "KAPI_PARAM_OPTIONAL" | "OPTIONAL" => return 1 << 3,
+            "KAPI_PARAM_CONST" | "CONST" => return 1 << 4,
+            "KAPI_PARAM_VOLATILE" | "VOLATILE" => return 1 << 5,
+            "KAPI_PARAM_USER" | "USER" => return 1 << 6,
+            "KAPI_PARAM_DMA" | "DMA" => return 1 << 7,
+            "KAPI_PARAM_ALIGNED" | "ALIGNED" => return 1 << 8,
+            _ => {}
+        }
+        // English short aliases (case-insensitive).
+        match t.to_ascii_lowercase().as_str() {
+            "input" => 1,
+            "output" => 2,
+            "inout" => 3,
+            "optional" => 1 << 3,
+            "const" => 1 << 4,
+            "volatile" => 1 << 5,
+            "user" => 1 << 6,
+            "dma" => 1 << 7,
+            "aligned" => 1 << 8,
+            _ => 0,
+        }
+    }
+
+    /// Map a KAPI_LOCK_* token to the numeric value declared in
+    /// `enum kapi_lock_type` in `include/linux/kernel_api_spec.h`.
+    fn parse_lock_type(&self, type_str: &str) -> u32 {
+        let s = type_str.trim();
+        match s {
+            "KAPI_LOCK_NONE" => 0,
+            "KAPI_LOCK_MUTEX" => 1,
+            "KAPI_LOCK_SPINLOCK" => 2,
+            "KAPI_LOCK_RWLOCK" => 3,
+            "KAPI_LOCK_SEQLOCK" => 4,
+            "KAPI_LOCK_RCU" => 5,
+            "KAPI_LOCK_SEMAPHORE" => 6,
+            "KAPI_LOCK_CUSTOM" => 7,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "none" => 0,
+                "mutex" => 1,
+                "spinlock" => 2,
+                "rwlock" => 3,
+                "seqlock" => 4,
+                "rcu" => 5,
+                "semaphore" => 6,
+                "custom" => 7,
+                _ => 0,
+            },
+        }
+    }
+
+    fn parse_signal_direction(&self, dir: &str) -> u32 {
+        let s = dir.trim();
+        match s {
+            "KAPI_SIGNAL_RECEIVE" => 1,
+            "KAPI_SIGNAL_SEND" => 2,
+            "KAPI_SIGNAL_HANDLE" => 4,
+            "KAPI_SIGNAL_BLOCK" => 8,
+            "KAPI_SIGNAL_IGNORE" => 16,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "receive" => 1,
+                "send" => 2,
+                "handle" => 4,
+                "block" => 8,
+                "ignore" => 16,
+                _ => 0,
+            },
+        }
+    }
+
+    fn parse_signal_action(&self, action: &str) -> u32 {
+        let s = action.trim();
+        match s {
+            "KAPI_SIGNAL_ACTION_DEFAULT" => 0,
+            "KAPI_SIGNAL_ACTION_TERMINATE" => 1,
+            "KAPI_SIGNAL_ACTION_COREDUMP" => 2,
+            "KAPI_SIGNAL_ACTION_STOP" => 3,
+            "KAPI_SIGNAL_ACTION_CONTINUE" => 4,
+            "KAPI_SIGNAL_ACTION_CUSTOM" => 5,
+            "KAPI_SIGNAL_ACTION_RETURN" => 6,
+            "KAPI_SIGNAL_ACTION_RESTART" => 7,
+            "KAPI_SIGNAL_ACTION_QUEUE" => 8,
+            "KAPI_SIGNAL_ACTION_DISCARD" => 9,
+            "KAPI_SIGNAL_ACTION_TRANSFORM" => 10,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "default" => 0,
+                "terminate" => 1,
+                "coredump" => 2,
+                "stop" => 3,
+                "continue" => 4,
+                "custom" => 5,
+                "return" => 6,
+                "restart" => 7,
+                "queue" => 8,
+                "discard" => 9,
+                "transform" => 10,
+                _ => 0,
+            },
+        }
+    }
+
+    fn parse_signal_timing(&self, timing: &str) -> u32 {
+        let s = timing.trim();
+        match s {
+            "KAPI_SIGNAL_TIME_BEFORE" => 0,
+            "KAPI_SIGNAL_TIME_DURING" => 1,
+            "KAPI_SIGNAL_TIME_AFTER" => 2,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "before" => 0,
+                "during" => 1,
+                "after" => 2,
+                _ => 0,
+            },
+        }
+    }
+
+    /// Accept a hex literal ("0x4"), a decimal literal ("4"), or a '|'-separated
+    /// bitmask expression. Unknown tokens contribute 0.
+    fn parse_hex_or_bitmask(&self, value: &str) -> u32 {
+        let v = value.trim();
+        if let Some(hex) = v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) {
+            if let Ok(n) = u32::from_str_radix(hex, 16) {
+                return n;
+            }
+        }
+        if let Ok(n) = v.parse::<u32>() {
+            return n;
+        }
+        let mut acc = 0u32;
+        for part in v.split(['|', ',']) {
+            let t = part.trim();
+            if t.is_empty() {
+                continue;
+            }
+            if let Some(hex) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
+                if let Ok(n) = u32::from_str_radix(hex, 16) {
+                    acc |= n;
+                    continue;
+                }
+            }
+            if let Ok(n) = t.parse::<u32>() {
+                acc |= n;
+            }
+        }
+        acc
+    }
+
+    /// Parse a '|'-separated list of KAPI_SIGNAL_STATE_* tokens (or short
+    /// names like "RUNNING") and OR their bit values together. Matches the
+    /// BIT(N) definitions in kernel_api_spec.h.
+    fn parse_signal_state_mask(&self, value: &str) -> u32 {
+        let mut acc = 0u32;
+        for part in value.split(['|', ',']) {
+            let t = part.trim().trim_start_matches("KAPI_SIGNAL_STATE_");
+            let bit = match t.to_ascii_uppercase().as_str() {
+                "RUNNING" => 1 << 0,
+                "SLEEPING" => 1 << 1,
+                "STOPPED" => 1 << 2,
+                "TRACED" => 1 << 3,
+                "ZOMBIE" => 1 << 4,
+                "DEAD" => 1 << 5,
+                _ => 0,
+            };
+            acc |= bit;
+        }
+        acc
+    }
+
+    /// Bitmask of `KAPI_EFFECT_*` values joined by '|' or ','.
+    /// Values match `enum kapi_side_effect_type` in
+    /// `include/linux/kernel_api_spec.h`.
+    fn parse_effect_type(&self, type_str: &str) -> u32 {
+        let sep = if type_str.contains('|') || !type_str.contains(',') {
+            '|'
+        } else {
+            ','
+        };
+        let mut result = 0;
+        for flag in type_str.split(sep) {
+            let t = flag.trim();
+            let bit = match t {
+                "KAPI_EFFECT_NONE" => 0,
+                "KAPI_EFFECT_ALLOC_MEMORY" => 1 << 0,
+                "KAPI_EFFECT_FREE_MEMORY" => 1 << 1,
+                "KAPI_EFFECT_MODIFY_STATE" => 1 << 2,
+                "KAPI_EFFECT_SIGNAL_SEND" => 1 << 3,
+                "KAPI_EFFECT_FILE_POSITION" => 1 << 4,
+                "KAPI_EFFECT_LOCK_ACQUIRE" => 1 << 5,
+                "KAPI_EFFECT_LOCK_RELEASE" => 1 << 6,
+                "KAPI_EFFECT_RESOURCE_CREATE" => 1 << 7,
+                "KAPI_EFFECT_RESOURCE_DESTROY" => 1 << 8,
+                "KAPI_EFFECT_SCHEDULE" => 1 << 9,
+                "KAPI_EFFECT_HARDWARE" => 1 << 10,
+                "KAPI_EFFECT_NETWORK" => 1 << 11,
+                "KAPI_EFFECT_FILESYSTEM" => 1 << 12,
+                "KAPI_EFFECT_PROCESS_STATE" => 1 << 13,
+                "KAPI_EFFECT_IRREVERSIBLE" => 1 << 14,
+                _ => match t.to_ascii_lowercase().as_str() {
+                    "none" => 0,
+                    "alloc_memory" => 1 << 0,
+                    "free_memory" => 1 << 1,
+                    "modify_state" => 1 << 2,
+                    "signal_send" => 1 << 3,
+                    "file_position" => 1 << 4,
+                    "lock_acquire" => 1 << 5,
+                    "lock_release" => 1 << 6,
+                    "resource_create" => 1 << 7,
+                    "resource_destroy" => 1 << 8,
+                    "schedule" => 1 << 9,
+                    "hardware" => 1 << 10,
+                    "network" => 1 << 11,
+                    "filesystem" => 1 << 12,
+                    "process_state" => 1 << 13,
+                    "irreversible" => 1 << 14,
+                    _ => 0,
+                },
+            };
+            result |= bit;
+        }
+        result
+    }
+
+    fn parse_capability_value(&self, cap: &str) -> i32 {
+        match cap {
+            "CAP_CHOWN" => 0,
+            "CAP_DAC_OVERRIDE" => 1,
+            "CAP_DAC_READ_SEARCH" => 2,
+            "CAP_FOWNER" => 3,
+            "CAP_FSETID" => 4,
+            "CAP_KILL" => 5,
+            "CAP_SETGID" => 6,
+            "CAP_SETUID" => 7,
+            "CAP_SETPCAP" => 8,
+            "CAP_LINUX_IMMUTABLE" => 9,
+            "CAP_NET_BIND_SERVICE" => 10,
+            "CAP_NET_BROADCAST" => 11,
+            "CAP_NET_ADMIN" => 12,
+            "CAP_NET_RAW" => 13,
+            "CAP_IPC_LOCK" => 14,
+            "CAP_IPC_OWNER" => 15,
+            "CAP_SYS_MODULE" => 16,
+            "CAP_SYS_RAWIO" => 17,
+            "CAP_SYS_CHROOT" => 18,
+            "CAP_SYS_PTRACE" => 19,
+            "CAP_SYS_PACCT" => 20,
+            "CAP_SYS_ADMIN" => 21,
+            "CAP_SYS_BOOT" => 22,
+            "CAP_SYS_NICE" => 23,
+            "CAP_SYS_RESOURCE" => 24,
+            "CAP_SYS_TIME" => 25,
+            "CAP_SYS_TTY_CONFIG" => 26,
+            "CAP_MKNOD" => 27,
+            "CAP_LEASE" => 28,
+            "CAP_AUDIT_WRITE" => 29,
+            "CAP_AUDIT_CONTROL" => 30,
+            "CAP_SETFCAP" => 31,
+            "CAP_MAC_OVERRIDE" => 32,
+            "CAP_MAC_ADMIN" => 33,
+            "CAP_SYSLOG" => 34,
+            "CAP_WAKE_ALARM" => 35,
+            "CAP_BLOCK_SUSPEND" => 36,
+            "CAP_AUDIT_READ" => 37,
+            "CAP_PERFMON" => 38,
+            "CAP_BPF" => 39,
+            "CAP_CHECKPOINT_RESTORE" => 40,
+            _ => 0,
+        }
+    }
+
+    /// Map a KAPI_RETURN_* token to the numeric value declared in
+    /// `enum kapi_return_check_type` in `include/linux/kernel_api_spec.h`.
+    fn parse_return_check_type(&self, check: &str) -> u32 {
+        let s = check.trim();
+        match s {
+            "KAPI_RETURN_EXACT" => 0,
+            "KAPI_RETURN_RANGE" => 1,
+            "KAPI_RETURN_ERROR_CHECK" => 2,
+            "KAPI_RETURN_FD" => 3,
+            "KAPI_RETURN_CUSTOM" => 4,
+            "KAPI_RETURN_NO_RETURN" => 5,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "exact" => 0,
+                "range" => 1,
+                "error_check" => 2,
+                "fd" => 3,
+                "custom" => 4,
+                "no_return" => 5,
+                _ => 0,
+            },
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn parser() -> KerneldocParser {
+        KerneldocParser::new()
+    }
+
+    #[test]
+    fn parse_minimal_kerneldoc() {
+        let doc = "\
+sys_foo - Do something useful
+context-flags: KAPI_CTX_PROCESS
+param-count: 1
+@fd: The file descriptor
+error: EBADF, Bad file descriptor
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_foo", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.name, "sys_foo");
+        assert_eq!(spec.api_type, "syscall");
+        assert_eq!(spec.description.as_deref(), Some("Do something useful"));
+        assert_eq!(spec.param_count, Some(1));
+        assert_eq!(spec.parameters.len(), 1);
+        assert_eq!(spec.parameters[0].name, "fd");
+        assert_eq!(spec.parameters[0].description, "The file descriptor");
+        assert_eq!(spec.errors.len(), 1);
+        assert_eq!(spec.errors[0].name, "EBADF");
+        assert_eq!(spec.errors[0].error_code, -9);
+    }
+
+    #[test]
+    fn parse_multiple_param_types() {
+        let doc = "\
+sys_bar - Multiple params
+@fd: file descriptor arg
+@buf: user buffer
+@count: byte count
+@flags: option flags
+param: fd
+  type: KAPI_TYPE_FD
+param: buf
+  type: KAPI_TYPE_USER_PTR
+param: count
+  type: KAPI_TYPE_UINT
+param: flags
+  type: KAPI_TYPE_UINT
+";
+        let sig = "(bar, int, fd, char __user *, buf, size_t, count, unsigned long, flags)";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_bar", "syscall", Some(sig))
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 4);
+
+        let fd_param = spec.parameters.iter().find(|p| p.name == "fd").unwrap();
+        assert_eq!(fd_param.param_type, 9); // FD (kernel enum)
+
+        let buf_param = spec.parameters.iter().find(|p| p.name == "buf").unwrap();
+        assert_eq!(buf_param.param_type, 10); // USER_PTR (kernel enum)
+        assert_eq!(buf_param.type_name, "char __user * buf");
+
+        let count_param = spec.parameters.iter().find(|p| p.name == "count").unwrap();
+        assert_eq!(count_param.param_type, 2); // UINT
+
+        let flags_param = spec.parameters.iter().find(|p| p.name == "flags").unwrap();
+        assert_eq!(flags_param.param_type, 2); // UINT
+    }
+
+    #[test]
+    fn parse_error_codes_with_descriptions() {
+        let doc = "\
+sys_err - Error test
+error: EBADF
+  desc: Bad file descriptor
+  condition: fd < 0
+error: EFAULT
+  desc: Bad user pointer
+  condition: buf is NULL
+error: EINVAL
+  desc: Invalid argument
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_err", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.errors.len(), 3);
+
+        assert_eq!(spec.errors[0].name, "EBADF");
+        assert_eq!(spec.errors[0].error_code, -9);
+        assert_eq!(spec.errors[0].description, "Bad file descriptor");
+        assert_eq!(spec.errors[0].condition, "fd < 0");
+
+        assert_eq!(spec.errors[1].name, "EFAULT");
+        assert_eq!(spec.errors[1].error_code, -14);
+        assert_eq!(spec.errors[1].description, "Bad user pointer");
+
+        assert_eq!(spec.errors[2].name, "EINVAL");
+        assert_eq!(spec.errors[2].error_code, -22);
+        assert_eq!(spec.errors[2].description, "Invalid argument");
+    }
+
+    #[test]
+    fn parse_context_flags() {
+        let doc = "\
+sys_ctx - Context test
+context-flags: KAPI_CTX_PROCESS|KAPI_CTX_SLEEPABLE
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ctx", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.context_flags.len(), 2);
+        assert_eq!(spec.context_flags[0], "KAPI_CTX_PROCESS");
+        assert_eq!(spec.context_flags[1], "KAPI_CTX_SLEEPABLE");
+    }
+
+    #[test]
+    fn parse_context_list_short() {
+        // "contexts: process, sleepable" -> KAPI_CTX_PROCESS | SLEEPABLE
+        let doc = "\
+sys_ctx - Context test
+contexts: process, sleepable
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ctx", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.context_flags,
+            vec![
+                "KAPI_CTX_PROCESS".to_string(),
+                "KAPI_CTX_SLEEPABLE".to_string(),
+            ]
+        );
+    }
+
+    #[test]
+    fn parse_context_list_mixed() {
+        // Short tokens intermixed with explicit KAPI_CTX_* still work.
+        let doc = "\
+sys_ctx - Context test
+contexts: process, KAPI_CTX_SLEEPABLE, softirq
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ctx", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.context_flags,
+            vec![
+                "KAPI_CTX_PROCESS".to_string(),
+                "KAPI_CTX_SLEEPABLE".to_string(),
+                "KAPI_CTX_SOFTIRQ".to_string(),
+            ]
+        );
+    }
+
+    #[test]
+    fn parse_context_flags_long_with_short_token() {
+        // Long-form "context-flags:" accepts "|"-joined short aliases.
+        let doc = "\
+sys_ctx - Context test
+context-flags: process | KAPI_CTX_SLEEPABLE
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ctx", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.context_flags,
+            vec![
+                "KAPI_CTX_PROCESS".to_string(),
+                "KAPI_CTX_SLEEPABLE".to_string(),
+            ]
+        );
+    }
+
+    #[test]
+    fn parse_param_type_short_combined() {
+        // "type: uint, input" combines the type and flag aliases.
+        let doc = "\
+sys_t - Short type test
+param: size
+  type: uint, input
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_t", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        assert_eq!(spec.parameters[0].param_type, 2); // KAPI_TYPE_UINT
+        assert_eq!(spec.parameters[0].flags, 1); // KAPI_PARAM_IN
+    }
+
+    #[test]
+    fn parse_param_type_short_multi_flag() {
+        // "type: path, input, user" sets both the IN and USER flags.
+        let doc = "\
+sys_t - Short type test
+param: filename
+  type: path, input, user
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_t", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        assert_eq!(spec.parameters[0].param_type, 11); // PATH (kernel enum)
+        assert_eq!(spec.parameters[0].flags, 1 | (1 << 6)); // IN | USER
+    }
+
+    #[test]
+    fn parse_constraint_type_range_expr() {
+        // Short form: "constraint-type: range(0, 4096)" replaces the
+        // two-line long form "constraint-type: KAPI_CONSTRAINT_RANGE"
+        // + "range: 0, 4096".
+        let doc = "\
+sys_c - Constraint test
+param: count
+  type: uint, input
+  constraint-type: range(0, 4096)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 1); // KAPI_CONSTRAINT_RANGE
+        assert_eq!(p.min_value, Some(0));
+        assert_eq!(p.max_value, Some(4096));
+    }
+
+    #[test]
+    fn parse_constraint_type_mask_expr() {
+        let doc = "\
+sys_c - Constraint test
+param: flags
+  type: uint, input
+  constraint-type: mask(0xff)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 2); // KAPI_CONSTRAINT_MASK
+        assert_eq!(p.valid_mask, Some(0xff));
+    }
+
+    #[test]
+    fn parse_constraint_type_enum_expr() {
+        let doc = "\
+sys_c - Constraint test
+param: mode
+  type: int, input
+  constraint-type: enum(0, 0x10, -3, MODE_FAST)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 3); // KAPI_CONSTRAINT_ENUM
+        assert_eq!(p.enum_values, ["0", "16", "-3", "MODE_FAST"]);
+    }
+
+    #[test]
+    fn c_integer_literals() {
+        assert_eq!(parse_u64_literal("0"), Some(0));
+        assert_eq!(parse_u64_literal("010"), Some(8));
+        assert_eq!(parse_u64_literal("0755"), Some(0o755));
+        assert_eq!(parse_u64_literal("0x1F"), Some(31));
+        assert_eq!(parse_u64_literal("0b101"), Some(5));
+        assert_eq!(parse_u64_literal("4096UL"), Some(4096));
+        assert_eq!(parse_u64_literal("0xffULL"), Some(255));
+        assert_eq!(parse_u64_literal("08"), None);
+        assert_eq!(parse_u64_literal("0x"), None);
+        assert_eq!(parse_u64_literal("+5"), None);
+        assert_eq!(parse_u64_literal("PAGE_SIZE"), None);
+        assert_eq!(parse_i64_literal("-010"), Some(-8));
+        assert_eq!(parse_i64_literal("-1U"), Some(4294967295));
+        assert_eq!(parse_i64_literal("-0x80000000"), Some(2147483648));
+        assert_eq!(parse_i64_literal("-2147483648"), Some(-2147483648));
+        assert_eq!(parse_i64_literal("-1UL"), Some(-1));
+        assert_eq!(parse_i64_literal("0xFFFFFFFFFFFFFFFF"), Some(-1));
+        assert_eq!(parse_i64_literal("18446744073709551615"), Some(-1));
+        assert_eq!(parse_i64_literal("-0x8000000000000000"), Some(i64::MIN));
+        assert_eq!(parse_u64_literal("-1"), Some(u64::MAX));
+        assert_eq!(parse_u64_literal("1uu"), None);
+        assert_eq!(parse_u64_literal("1lll"), None);
+    }
+
+    #[test]
+    fn parse_constraint_c_literals() {
+        let doc = "\
+sys_c - Constraint test
+param: mode
+  type: int, input
+  constraint-type: enum(010, 0x10, -07, 0b11, 5U)
+param: perms
+  type: uint, input
+  constraint-type: mask(0755)
+param: len
+  type: uint, input
+  constraint-type: range(01, 0x1000UL)
+param: align
+  type: uint, input
+  constraint-type: alignment(0x10)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters;
+        assert_eq!(p[0].enum_values, ["8", "16", "-7", "3", "5"]);
+        assert_eq!(p[1].valid_mask, Some(0o755));
+        assert_eq!((p[2].min_value, p[2].max_value), (Some(1), Some(4096)));
+        assert_eq!(p[3].alignment, Some(16));
+    }
+
+    #[test]
+    fn zero_user_string_limits_are_unset() {
+        let doc = "\
+sys_s - String test
+param: name
+  type: user_ptr, input
+  range: 0, 255
+  constraint-type: user_string
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_s", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 8);
+        assert_eq!((p.min_value, p.max_value), (None, Some(255)));
+    }
+
+    #[test]
+    fn user_ptr_type_implies_user_flag() {
+        let doc = "\
+sys_u - Implicit user flag test
+param: buf
+  type: user_ptr, output
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_u", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.param_type, 10); // KAPI_TYPE_USER_PTR
+        assert_eq!(
+            p.flags,
+            (1 << 1) | (1 << 6), // OUT | USER
+            "user_ptr type must imply KAPI_PARAM_USER"
+        );
+    }
+
+    #[test]
+    fn fd_type_does_not_imply_user_flag() {
+        // Only user_ptr / path imply KAPI_PARAM_USER. fd, int, uint,
+        // and every other non-user-space type must leave flags alone.
+        let doc = "\
+sys_fd - fd has no implicit user flag
+param: fd
+  type: fd, input
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_fd", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.param_type, 9);
+        assert_eq!(p.flags, 1, "fd must not auto-set KAPI_PARAM_USER");
+    }
+
+    #[test]
+    fn path_type_implies_user_flag() {
+        let doc = "\
+sys_p - Path implicit user flag
+param: filename
+  type: path, input
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_p", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.param_type, 11);
+        assert_eq!(
+            p.flags,
+            1 | (1 << 6), // IN | USER
+            "path type must imply KAPI_PARAM_USER"
+        );
+    }
+
+    #[test]
+    fn short_form_enum_equivalence() {
+        // Short-form and long-form renderings of the same spec must
+        // produce identical ApiSpec output across every enum family:
+        // context flags, param type+flags, constraint type, lock type,
+        // signal direction/action/timing, capability action, side-effect
+        // bitmask, return check type.
+        let long = "\
+sys_x - Enum short form test
+context-flags: KAPI_CTX_PROCESS | KAPI_CTX_SLEEPABLE
+
+param: fd
+  type: KAPI_TYPE_FD
+  flags: KAPI_PARAM_IN
+
+lock: files->file_lock
+  type: KAPI_LOCK_SPINLOCK
+  scope: acquires
+  desc: table lock
+
+signal: pending_signals
+  direction: KAPI_SIGNAL_RECEIVE
+  action: KAPI_SIGNAL_ACTION_RETURN
+  timing: KAPI_SIGNAL_TIME_DURING
+  desc: sig
+
+capability: CAP_SYS_ADMIN
+  type: KAPI_CAP_BYPASS_CHECK
+
+return:
+  type: KAPI_TYPE_INT
+  check-type: KAPI_RETURN_FD
+  desc: fd or errno
+
+side-effect: KAPI_EFFECT_RESOURCE_CREATE | KAPI_EFFECT_ALLOC_MEMORY
+  target: t
+  desc: d
+";
+        let short = "\
+sys_x - Enum short form test
+contexts: process, sleepable
+
+param: fd
+  type: fd, input
+
+lock: files->file_lock
+  type: spinlock
+  scope: acquires
+  desc: table lock
+
+signal: pending_signals
+  direction: receive
+  action: return
+  timing: during
+  desc: sig
+
+capability: CAP_SYS_ADMIN
+  type: bypass_check
+
+return:
+  type: int
+  check-type: fd
+  desc: fd or errno
+
+side-effect: resource_create | alloc_memory
+  target: t
+  desc: d
+";
+        let mut sp_l = parser()
+            .parse_kerneldoc(long, "sys_x", "syscall", None)
+            .unwrap();
+        let mut sp_s = parser()
+            .parse_kerneldoc(short, "sys_x", "syscall", None)
+            .unwrap();
+        // The return type name is the human spelling, kept as written.
+        for sp in [&mut sp_l, &mut sp_s] {
+            sp.return_spec.as_mut().unwrap().type_name.clear();
+        }
+        assert_eq!(
+            format!("{:#?}", sp_l),
+            format!("{:#?}", sp_s),
+            "long-form and short-form of every enum family must normalise identically"
+        );
+    }
+
+    #[test]
+    fn parse_buffer_short_captures_size_param_idx() {
+        let doc = "\
+sys_b - Buffer test
+param: buf
+  type: user_ptr, output, user
+  constraint-type: buffer(2)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_b", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters[0].constraint_type, 11);
+        assert_eq!(spec.parameters[0].size_param_idx, Some(2));
+    }
+
+    #[test]
+    fn buffer_short_and_size_param_long_are_symmetric() {
+        let short = "\
+sys_b - Symmetric buffer test
+param: buf
+  type: user_ptr, output, user
+  constraint-type: buffer(2)
+";
+        let long = "\
+sys_b - Symmetric buffer test
+param: buf
+  type: KAPI_TYPE_USER_PTR
+  flags: KAPI_PARAM_OUT | KAPI_PARAM_USER
+  constraint-type: KAPI_CONSTRAINT_BUFFER
+  size-param: 2
+";
+        let sp_s = parser()
+            .parse_kerneldoc(short, "sys_b", "syscall", None)
+            .unwrap();
+        let sp_l = parser()
+            .parse_kerneldoc(long, "sys_b", "syscall", None)
+            .unwrap();
+        assert_eq!(format!("{:#?}", sp_s), format!("{:#?}", sp_l));
+    }
+
+    #[test]
+    fn parse_constraint_type_bare_user_path() {
+        let doc = "\
+sys_c - Constraint test
+param: filename
+  type: path, input, user
+  constraint-type: user_path
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters[0].constraint_type, 9); // USER_PATH
+    }
+
+    #[test]
+    fn starts_subfield_requires_known_key() {
+        let keys = block_subfield_keys(&BlockContext::SideEffect);
+        assert!(super::starts_subfield("target: file table", keys));
+        assert!(super::starts_subfield("condition:", keys));
+        assert!(super::starts_subfield("reversible: yes", keys));
+        // A colon inside prose does not open a subfield.
+        assert!(!super::starts_subfield(
+            "this lock mid-operation: the handler",
+            keys
+        ));
+        assert!(!super::starts_subfield("Careful: it is dangerous", keys));
+        // A key that belongs to another block type does not count.
+        assert!(!super::starts_subfield("direction: receive", keys));
+        assert!(!super::starts_subfield("O_RDONLY | O_WRONLY |", keys));
+        assert!(!super::starts_subfield(")", keys));
+    }
+
+    #[test]
+    fn multiline_fold_stops_at_sibling_block_attribute() {
+        // The constraint-type's continuation must not greedily eat the
+        // next subfield of the same block.
+        let doc = "\
+sys_y - Fold stop test
+param: f
+  type: int, input
+  constraint-type: mask(FOO |
+                        BAR)
+  cdesc: something about f
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_y", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        // If the fold over-consumed, `cdesc:` would have been swallowed
+        // into the mask expression and param.constraint_type would be 0.
+        assert_eq!(spec.parameters[0].constraint_type, 2);
+        assert_eq!(
+            spec.parameters[0].constraint.as_deref(),
+            Some("something about f")
+        );
+    }
+
+    #[test]
+    fn parse_constraint_type_mask_expr_multiline() {
+        // Real-world sys_open/flags case: a symbolic mask split across
+        // four continuation lines. The parser must fold the continuation
+        // lines before running the function-call match, otherwise the
+        // constraint type silently decays to 0.
+        let doc = "\
+sys_x - Multi-line mask test
+param: f
+  type: int, input
+  constraint-type: mask(O_RDONLY | O_WRONLY | O_RDWR | O_CREAT | O_EXCL | O_NOCTTY |
+                        O_TRUNC | O_APPEND | O_NONBLOCK | O_DSYNC | O_SYNC | FASYNC |
+                        O_DIRECT | O_LARGEFILE | O_DIRECTORY | O_NOFOLLOW | O_NOATIME |
+                        O_CLOEXEC | O_PATH | O_TMPFILE)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_x", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 2, "multi-line mask must set MASK type");
+        // Symbolic mask: must stay unresolved rather than becoming Some(0).
+        assert_eq!(
+            p.valid_mask, None,
+            "symbolic mask values must remain None, not Some(0)"
+        );
+    }
+
+    #[test]
+    fn parse_constraint_long_form() {
+        let doc = "\
+sys_c - Constraint test
+param: foo
+  type: uint, input
+  constraint-type: KAPI_CONSTRAINT_MASK
+  valid-mask: 0xff
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 2); // KAPI_CONSTRAINT_MASK
+    }
+
+    #[test]
+    fn parse_constraint_free_text() {
+        // `constraint:` carries free-text constraint description;
+        // function-call short form lives on `constraint-type:`.
+        let doc = "\
+sys_c - Constraint test
+param: foo
+  type: uint, input
+  constraint: must be a valid page descriptor
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 0);
+        assert_eq!(
+            p.constraint.as_deref(),
+            Some("must be a valid page descriptor")
+        );
+    }
+
+    #[test]
+    fn parse_description_alias_overrides_kerneldoc() {
+        // `description:` inside a `param:` block is an alias for `desc:`
+        // and overrides the @param description.
+        let doc = "\
+sys_d - Description alias test
+@size: kerneldoc short description
+param: size
+  type: uint, input
+  description: The new long form description.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_d", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        assert_eq!(
+            spec.parameters[0].description,
+            "The new long form description."
+        );
+    }
+
+    #[test]
+    fn canonical_equivalence_short_vs_long() {
+        // Two spellings of the same spec must produce identical ApiSpec
+        // JSON.
+        let long = "\
+sys_open - open a file
+context-flags: KAPI_CTX_PROCESS | KAPI_CTX_SLEEPABLE
+
+param: filename
+  type: KAPI_TYPE_PATH
+  flags: KAPI_PARAM_IN | KAPI_PARAM_USER
+  constraint-type: KAPI_CONSTRAINT_USER_PATH
+  desc: Pathname to open
+
+param: count
+  type: KAPI_TYPE_UINT
+  flags: KAPI_PARAM_IN
+  constraint-type: KAPI_CONSTRAINT_RANGE
+  range: 0, 4096
+  desc: Byte count
+";
+        let short = "\
+sys_open - open a file
+contexts: process, sleepable
+
+param: filename
+  type: path, input, user
+  constraint-type: user_path
+  description: Pathname to open
+
+param: count
+  type: uint, input
+  constraint-type: range(0, 4096)
+  description: Byte count
+";
+        let long_spec = parser()
+            .parse_kerneldoc(long, "sys_open", "syscall", None)
+            .unwrap();
+        let short_spec = parser()
+            .parse_kerneldoc(short, "sys_open", "syscall", None)
+            .unwrap();
+
+        // ApiSpec isn't Serialize as a whole, so compare the Debug
+        // rendering, which still proves every field canonicalises
+        // identically.
+        let d_long = format!("{:#?}", long_spec);
+        let d_short = format!("{:#?}", short_spec);
+        assert_eq!(
+            d_long, d_short,
+            "short-form and long-form specs must normalise identically"
+        );
+    }
+
+    #[test]
+    fn parse_capability_block() {
+        let doc = "\
+sys_cap - Capability test
+capability: CAP_SYS_ADMIN
+  type: required
+  allows: Full system administration
+  without: Operation not permitted
+  condition: always
+  priority: 5
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_cap", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.capabilities.len(), 1);
+        let cap = &spec.capabilities[0];
+        assert_eq!(cap.capability, 21); // CAP_SYS_ADMIN
+        assert_eq!(cap.action, "required");
+        assert_eq!(cap.allows, "Full system administration");
+        assert_eq!(cap.without_cap, "Operation not permitted");
+        assert_eq!(cap.check_condition.as_deref(), Some("always"));
+        assert_eq!(cap.priority, Some(5));
+    }
+
+    #[test]
+    fn parse_lock_block() {
+        let doc = "\
+sys_lock - Lock test
+lock: files_lock, KAPI_LOCK_MUTEX
+  scope: acquires
+  desc: Protects file table
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_lock", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.locks.len(), 1);
+        let lock = &spec.locks[0];
+        assert_eq!(lock.lock_name, "files_lock");
+        assert_eq!(lock.lock_type, 1); // MUTEX
+        assert_eq!(lock.scope, super::super::KAPI_LOCK_ACQUIRES);
+        assert_eq!(lock.description, "Protects file table");
+    }
+
+    #[test]
+    fn parse_lock_acquired_released_flags() {
+        let doc = "\
+sys_lock - Lock test
+lock: a_lock, KAPI_LOCK_MUTEX
+  acquired: true
+  released: true
+lock: b_lock, KAPI_LOCK_MUTEX
+  acquired: true
+lock: c_lock, KAPI_LOCK_MUTEX
+  released: yes
+lock: d_lock, KAPI_LOCK_MUTEX
+  acquired: conditional
+  released: false
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_lock", "syscall", None)
+            .unwrap();
+
+        let scopes: Vec<u32> = spec.locks.iter().map(|l| l.scope).collect();
+        assert_eq!(
+            scopes,
+            vec![
+                super::super::KAPI_LOCK_INTERNAL,
+                super::super::KAPI_LOCK_ACQUIRES,
+                super::super::KAPI_LOCK_RELEASES,
+                super::super::KAPI_LOCK_INTERNAL,
+            ]
+        );
+    }
+
+    #[test]
+    fn parse_signal_block() {
+        let doc = "\
+sys_sig - Signal test
+signal: SIGKILL
+  direction: KAPI_SIGNAL_RECEIVE
+  action: KAPI_SIGNAL_ACTION_TERMINATE
+  timing: KAPI_SIGNAL_TIME_DURING
+  priority: 3
+  restartable: yes
+  interruptible: yes
+  desc: Process termination signal
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_sig", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.signals.len(), 1);
+        let sig = &spec.signals[0];
+        assert_eq!(sig.signal_name, "SIGKILL");
+        assert_eq!(sig.direction, 1); // RECEIVE
+        assert_eq!(sig.action, 1); // TERMINATE
+        assert_eq!(sig.timing, 1); // DURING
+        assert_eq!(sig.priority, 3);
+        assert!(sig.restartable);
+        assert!(sig.interruptible);
+        assert_eq!(
+            sig.description.as_deref(),
+            Some("Process termination signal")
+        );
+    }
+
+    #[test]
+    fn parse_signal_errno_shapes() {
+        // All three accepted spellings of the signal errno field must
+        // produce the same negative kernel return code.
+        for (form, label) in [
+            ("errno: -EINTR", "-EINTR symbolic"),
+            ("errno: EINTR", "bare symbolic"),
+            ("errno: -4", "numeric literal"),
+        ] {
+            let doc = format!(
+                "sys_s - Signal errno test\n\
+                 signal: SIGINT\n\
+                 \x20 direction: receive\n\
+                 \x20 action: return\n\
+                 \x20 {}\n",
+                form,
+            );
+            let spec = parser()
+                .parse_kerneldoc(&doc, "sys_s", "syscall", None)
+                .unwrap();
+            assert_eq!(spec.signals.len(), 1, "{label}");
+            assert_eq!(
+                spec.signals[0].error_on_signal,
+                Some(-4),
+                "errno form {label:?} must resolve to -EINTR (-4)",
+            );
+        }
+    }
+
+    #[test]
+    fn parse_side_effect_flat() {
+        let doc = "\
+sys_se - Side effect test
+side-effect: KAPI_EFFECT_MODIFY_STATE, file_table, Allocates a new file descriptor
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_se", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.side_effects.len(), 1);
+        let se = &spec.side_effects[0];
+        assert_eq!(se.effect_type, 1 << 2); // KAPI_EFFECT_MODIFY_STATE
+        assert_eq!(se.target, "file_table");
+        assert_eq!(se.description, "Allocates a new file descriptor");
+    }
+
+    #[test]
+    fn parse_side_effect_block() {
+        let doc = "\
+sys_se2 - Side effect block test
+side-effect: KAPI_EFFECT_ALLOC_MEMORY
+  target: kernel_heap
+  desc: Allocates kernel memory
+  reversible: yes
+  condition: size > 0
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_se2", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.side_effects.len(), 1);
+        let se = &spec.side_effects[0];
+        assert_eq!(se.effect_type, 1 << 0); // KAPI_EFFECT_ALLOC_MEMORY
+        assert_eq!(se.target, "kernel_heap");
+        assert_eq!(se.description, "Allocates kernel memory");
+        assert!(se.reversible);
+        assert_eq!(se.condition.as_deref(), Some("size > 0"));
+    }
+
+    #[test]
+    fn parse_empty_doc_no_error() {
+        let doc = "";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_empty", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.name, "sys_empty");
+        assert!(spec.description.is_none());
+        assert!(spec.parameters.is_empty());
+        assert!(spec.errors.is_empty());
+        assert!(spec.signals.is_empty());
+        assert!(spec.capabilities.is_empty());
+        assert!(spec.locks.is_empty());
+        assert!(spec.side_effects.is_empty());
+        assert!(spec.context_flags.is_empty());
+    }
+
+    #[test]
+    fn parse_missing_sections_no_error() {
+        // Only has a description, no KAPI annotations
+        let doc = "\
+sys_simple - Just a simple syscall
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_simple", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.description.as_deref(), Some("Just a simple syscall"));
+        assert!(spec.parameters.is_empty());
+        assert!(spec.errors.is_empty());
+        assert!(spec.context_flags.is_empty());
+    }
+
+    #[test]
+    fn parse_constraint_block() {
+        let doc = "\
+sys_cst - Constraint test
+constraint: valid_fd
+  desc: File descriptor must be valid and open
+  expr: fd >= 0 && fd < NR_OPEN
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_cst", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.constraints.len(), 1);
+        let cst = &spec.constraints[0];
+        assert_eq!(cst.name, "valid_fd");
+        assert_eq!(cst.description, "File descriptor must be valid and open");
+        assert_eq!(cst.expression.as_deref(), Some("fd >= 0 && fd < NR_OPEN"));
+    }
+
+    #[test]
+    fn parse_state_transition_flat() {
+        let doc = "\
+sys_st - State transition test
+state-trans: fd, open, closed, File descriptor is closed
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_st", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.state_transitions.len(), 1);
+        let st = &spec.state_transitions[0];
+        assert_eq!(st.object, "fd");
+        assert_eq!(st.from_state, "open");
+        assert_eq!(st.to_state, "closed");
+        assert_eq!(st.description, "File descriptor is closed");
+    }
+
+    #[test]
+    fn parse_param_block_with_range() {
+        let doc = "\
+sys_rng - Range test
+@count: byte count
+param: count
+  type: KAPI_TYPE_UINT
+  flags: IN
+  range: 0, 4096
+  constraint-type: KAPI_CONSTRAINT_RANGE
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_rng", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        let p = &spec.parameters[0];
+        assert_eq!(p.name, "count");
+        assert_eq!(p.param_type, 2); // UINT
+        assert_eq!(p.flags, 1); // IN
+        assert_eq!(p.min_value, Some(0));
+        assert_eq!(p.max_value, Some(4096));
+        assert_eq!(p.constraint_type, 1); // RANGE
+    }
+
+    #[test]
+    fn parse_return_block() {
+        let doc = "\
+sys_ret - Return test
+return:
+  type: KAPI_TYPE_INT
+  check-type: KAPI_RETURN_FD
+  success: 0
+  desc: Returns file descriptor on success
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ret", "syscall", None)
+            .unwrap();
+
+        let ret = spec.return_spec.as_ref().unwrap();
+        assert_eq!(ret.type_name, "KAPI_TYPE_INT");
+        assert_eq!(ret.return_type, 1); // INT
+        assert_eq!(ret.check_type, 3); // FD
+        assert_eq!(ret.success_value, None); // FD checks carry no success value
+        assert_eq!(ret.description, "Returns file descriptor on success");
+    }
+
+    #[test]
+    fn colon_in_continuation_line_does_not_start_subfield() {
+        let doc = "\
+sys_col - Colon continuation test
+param: x
+  type: int
+  cdesc: Must be sane. Note: this is a continuation
+    with a colon: in the middle.
+
+lock: mylock
+  type: mutex
+  desc: Taken at entry; if the caller drops
+    this lock mid-operation: the handler waits
+    until released.
+
+side-effect: modify_state
+  target: stuff
+  condition: only when a
+    special case applies: for example
+    on Tuesdays
+  desc: Does things.
+    Careful: it is dangerous.
+  reversible: no
+
+signal: SIGINT
+  direction: receive
+  condition: while blocked, unless
+    flagged: see below
+  desc: Interrupts the wait.
+
+capability: CAP_SYS_ADMIN
+  type: bypass_check
+  allows: Things
+    that need: privilege
+  condition: Always
+
+constraint: limit
+  desc: A limit
+    of note: nothing.
+  expr: a &&
+    b
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_col", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.parameters[0].constraint.as_deref(),
+            Some("Must be sane. Note: this is a continuation with a colon: in the middle.")
+        );
+        assert_eq!(
+            spec.locks[0].description,
+            "Taken at entry; if the caller drops this lock mid-operation: the handler waits until released."
+        );
+        let se = &spec.side_effects[0];
+        assert_eq!(
+            se.condition.as_deref(),
+            Some("only when a special case applies: for example on Tuesdays")
+        );
+        assert_eq!(se.description, "Does things. Careful: it is dangerous.");
+        assert!(!se.reversible);
+        let sig = &spec.signals[0];
+        assert_eq!(
+            sig.condition.as_deref(),
+            Some("while blocked, unless flagged: see below")
+        );
+        assert_eq!(sig.description.as_deref(), Some("Interrupts the wait."));
+        let cap = &spec.capabilities[0];
+        assert_eq!(cap.allows, "Things that need: privilege");
+        assert_eq!(cap.check_condition.as_deref(), Some("Always"));
+        let cst = &spec.constraints[0];
+        assert_eq!(cst.description, "A limit of note: nothing.");
+        assert_eq!(cst.expression.as_deref(), Some("a && b"));
+    }
+
+    #[test]
+    fn multiline_side_effect_condition_is_kept_whole() {
+        let doc = "\
+sys_mc - Multi-line condition test
+side-effect: modify_state
+  target: userfaultfd event queue
+  condition: MADV_DONTNEED, MADV_FREE
+    on a VMA whose userfaultfd context negotiated
+    UFFD_FEATURE_EVENT_REMOVE
+  desc: Generates a notification.
+    The monitor cannot veto it.
+  reversible: no
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_mc", "syscall", None)
+            .unwrap();
+
+        let se = &spec.side_effects[0];
+        assert_eq!(
+            se.condition.as_deref(),
+            Some(
+                "MADV_DONTNEED, MADV_FREE on a VMA whose userfaultfd context \
+                 negotiated UFFD_FEATURE_EVENT_REMOVE"
+            )
+        );
+        assert_eq!(
+            se.description,
+            "Generates a notification. The monitor cannot veto it."
+        );
+    }
+
+    #[test]
+    fn blank_line_inside_block_continues_subfield() {
+        let doc = "\
+sys_bl - Blank line test
+lock: l
+  type: mutex
+  desc: First half
+
+    second half.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_bl", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.locks[0].description, "First half second half.");
+    }
+
+    #[test]
+    fn param_cdesc_is_the_constraint_text() {
+        let doc = "\
+sys_cd - cdesc test
+param: fd
+  type: int, input
+  cdesc: Must be open.
+    Zero is allowed.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_cd", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.parameters[0].constraint.as_deref(),
+            Some("Must be open. Zero is allowed.")
+        );
+    }
+
+    #[test]
+    fn state_transition_block_keeps_condition_separate() {
+        let doc = "\
+sys_stb - State transition block test
+state-trans: file_descriptor
+  from: open
+  to: closed/free
+  condition: Valid fd passed to close
+  desc: The fd becomes unusable.
+    It may be reused.
+
+state-trans: refcount
+  from: n
+  to: n-1
+  desc: Decremented.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_stb", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.state_transitions.len(), 2);
+        let st = &spec.state_transitions[0];
+        assert_eq!(st.object, "file_descriptor");
+        assert_eq!(st.from_state, "open");
+        assert_eq!(st.to_state, "closed/free");
+        assert_eq!(st.condition.as_deref(), Some("Valid fd passed to close"));
+        assert_eq!(st.description, "The fd becomes unusable. It may be reused.");
+        let st = &spec.state_transitions[1];
+        assert_eq!(st.object, "refcount");
+        assert_eq!(st.condition, None);
+        assert_eq!(st.description, "Decremented.");
+    }
+
+    #[test]
+    fn state_transition_flat_description_keeps_commas() {
+        let doc = "\
+sys_stc - Flat state transition test
+state-trans: fd, open, closed, File is closed, and the number is free
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_stc", "syscall", None)
+            .unwrap();
+
+        let st = &spec.state_transitions[0];
+        assert_eq!(st.description, "File is closed, and the number is free");
+        assert_eq!(st.condition, None);
+    }
+
+    #[test]
+    fn return_type_name_is_kept_as_written() {
+        for (written, ty) in [("int", 1), ("long", 0), ("KAPI_TYPE_UINT", 2)] {
+            let doc = format!("sys_rt - Return type test\nreturn:\n  type: {written}\n");
+            let spec = parser()
+                .parse_kerneldoc(&doc, "sys_rt", "syscall", None)
+                .unwrap();
+            let ret = spec.return_spec.as_ref().unwrap();
+            assert_eq!(ret.type_name, written);
+            assert_eq!(ret.return_type, ty);
+        }
+    }
+
+    #[test]
+    fn return_success_follows_check_type() {
+        let cases = [
+            // (check-type, success, value, min, max)
+            ("exact", "0", Some(0), None, None),
+            ("exact", "= 0", Some(0), None, None),
+            ("exact", "== -1", Some(-1), None, None),
+            ("exact", "0x10", Some(16), None, None),
+            ("exact", ">= 0", None, None, None),
+            ("range", ">= 0", None, Some(0), Some(i64::MAX)),
+            ("range", ">= 1", None, Some(1), Some(i64::MAX)),
+            ("range", "0", None, Some(0), Some(i64::MAX)),
+            ("fd", ">= 0", None, None, None),
+            ("error_check", "0", None, None, None),
+        ];
+        for (check, success, value, min, max) in cases {
+            let doc = format!(
+                "sys_rs - Return success test\nreturn:\n  type: int\n  \
+                 success: {success}\n  check-type: {check}\n  desc: ok\n"
+            );
+            let spec = parser()
+                .parse_kerneldoc(&doc, "sys_rs", "syscall", None)
+                .unwrap();
+            let ret = spec.return_spec.as_ref().unwrap();
+            assert_eq!(ret.success_value, value, "{check} {success}");
+            assert_eq!(ret.success_min, min, "{check} {success}");
+            assert_eq!(ret.success_max, max, "{check} {success}");
+        }
+    }
+
+    #[test]
+    fn return_description_continuation_with_colon() {
+        let doc = "\
+sys_rd - Return description test
+return:
+  type: int
+  check-type: exact
+  success: 0
+  desc: Returns zero on success. Note: this is
+    a continuation line with a colon.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_rd", "syscall", None)
+            .unwrap();
+
+        let ret = spec.return_spec.as_ref().unwrap();
+        assert_eq!(
+            ret.description,
+            "Returns zero on success. Note: this is a continuation line with a colon."
+        );
+        assert_eq!(ret.success_value, Some(0));
+    }
+
+    #[test]
+    fn examples_keep_one_example_per_line() {
+        let doc = "\
+sys_ex - Examples test
+examples: fd = open(\"/etc/passwd\", O_RDONLY);  // Read existing file
+  fd = open(\"/tmp/new\", O_WRONLY | O_CREAT, 0644);  // Create
+  // Handle short writes:
+  while (total < len) {
+    n = write(fd, buf + total, len - total);
+    if (n < 0) break;
+  }
+
+  close(fd);
+
+notes: After the examples.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ex", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.examples.as_deref(),
+            Some(
+                "fd = open(\"/etc/passwd\", O_RDONLY);  // Read existing file\n\
+                 fd = open(\"/tmp/new\", O_WRONLY | O_CREAT, 0644);  // Create\n\
+                 // Handle short writes:\n\
+                 while (total < len) {\n  \
+                 n = write(fd, buf + total, len - total);\n  \
+                 if (n < 0) break;\n\
+                 }\n\
+                 \n\
+                 close(fd);"
+            )
+        );
+        assert_eq!(spec.notes.as_deref(), Some("After the examples."));
+    }
+
+    #[test]
+    fn notes_and_long_desc_keep_paragraphs_and_bullets() {
+        let doc = "\
+sys_nt - Notes test
+long-desc: First paragraph wraps
+  over two lines.
+
+  Second paragraph introduces a list:
+  - item one wraps
+    onto a second line
+  - item two
+
+  Last paragraph.
+notes: The behavior varies:
+
+  - Regular files: reads
+    from the current position.
+
+  - Pipes: block.
+
+  Trailing: text.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_nt", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.long_description.as_deref(),
+            Some(
+                "First paragraph wraps over two lines.\n\n\
+                 Second paragraph introduces a list:\n\
+                 - item one wraps onto a second line\n\
+                 - item two\n\n\
+                 Last paragraph."
+            )
+        );
+        assert_eq!(
+            spec.notes.as_deref(),
+            Some(
+                "The behavior varies:\n\n\
+                 - Regular files: reads from the current position.\n\n\
+                 - Pipes: block.\n\n\
+                 Trailing: text."
+            )
+        );
+    }
+
+    #[test]
+    fn fold_lines_handles_leading_blank_and_tabs() {
+        assert_eq!(
+            super::fold_lines(&["", "  a();", "  b();", "    c();", ""]),
+            "a();\nb();\n  c();"
+        );
+        assert_eq!(super::fold_lines(&["x();", "\ty();"]), "x();\ny();");
+        assert_eq!(super::fold_lines(&[""]), "");
+        assert_eq!(super::fold_lines(&["a", "  b", "", "", "  c"]), "a\nb\n\nc");
+    }
+
+    #[test]
+    fn fold_paragraphs_joins_wrapped_lines_only() {
+        assert_eq!(
+            super::fold_paragraphs(&["one", "  two", "", "", "  three", "- four", "- five"]),
+            "one two\n\nthree\n- four\n- five"
+        );
+        assert_eq!(super::fold_paragraphs(&["", "  "]), "");
+    }
+}
diff --git a/tools/kapi/src/extractor/mod.rs b/tools/kapi/src/extractor/mod.rs
new file mode 100644
index 0000000000000..d7ada57924d7a
--- /dev/null
+++ b/tools/kapi/src/extractor/mod.rs
@@ -0,0 +1,442 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use crate::formatter::OutputFormatter;
+use anyhow::Result;
+use std::io::Write;
+
+pub mod debugfs;
+pub mod kerneldoc_parser;
+pub mod source_parser;
+pub mod vmlinux;
+
+pub use debugfs::DebugfsExtractor;
+pub use source_parser::SourceExtractor;
+pub use vmlinux::VmlinuxExtractor;
+
+/// Capability specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct CapabilitySpec {
+    pub capability: i32,
+    pub name: String,
+    pub action: String,
+    pub allows: String,
+    pub without_cap: String,
+    pub check_condition: Option<String>,
+    pub priority: Option<u8>,
+    pub alternatives: Vec<i32>,
+}
+
+/// Parameter specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ParamSpec {
+    pub index: u32,
+    pub name: String,
+    pub type_name: String,
+    pub description: String,
+    pub flags: u32,
+    pub param_type: u32,
+    pub constraint_type: u32,
+    pub constraint: Option<String>,
+    pub min_value: Option<i64>,
+    pub max_value: Option<i64>,
+    pub valid_mask: Option<u64>,
+    pub enum_values: Vec<String>,
+    pub size: Option<u32>,
+    pub alignment: Option<u32>,
+    /// Index of the parameter that carries this parameter's byte count
+    /// (for KAPI_CONSTRAINT_BUFFER). Populated by either
+    /// `size-param: N` (long form) or `constraint-type: buffer(N)`
+    /// (short form).
+    pub size_param_idx: Option<u32>,
+}
+
+/// Constraint type enum values matching kernel enum kapi_constraint_type
+pub const KAPI_CONSTRAINT_RANGE: u32 = 1;
+pub const KAPI_CONSTRAINT_MASK: u32 = 2;
+pub const KAPI_CONSTRAINT_USER_STRING: u32 = 8;
+
+impl ParamSpec {
+    /// Clear the numeric fields the constraint type does not use. The
+    /// compiled struct always carries them, so an unset value reads as 0.
+    pub fn keep_used_numbers(&mut self) {
+        match self.constraint_type {
+            KAPI_CONSTRAINT_RANGE => {}
+            KAPI_CONSTRAINT_USER_STRING => self.drop_unset_string_limits(),
+            _ => {
+                self.min_value = None;
+                self.max_value = None;
+            }
+        }
+        if self.constraint_type != KAPI_CONSTRAINT_MASK {
+            self.valid_mask = None;
+        }
+        self.size = self.size.filter(|&n| n != 0);
+        self.alignment = self.alignment.filter(|&n| n != 0);
+    }
+
+    /// The kernel treats a user_string length limit of 0 as no limit.
+    pub fn drop_unset_string_limits(&mut self) {
+        if self.constraint_type == KAPI_CONSTRAINT_USER_STRING {
+            self.min_value = self.min_value.filter(|&n| n > 0);
+            self.max_value = self.max_value.filter(|&n| n > 0);
+        }
+    }
+}
+
+/// Return value specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ReturnSpec {
+    pub type_name: String,
+    pub description: String,
+    pub return_type: u32,
+    pub check_type: u32,
+    pub success_value: Option<i64>,
+    pub success_min: Option<i64>,
+    pub success_max: Option<i64>,
+    pub error_values: Vec<i32>,
+}
+
+impl ReturnSpec {
+    /// Drop the success fields the check type does not use, the way the
+    /// generated KAPI_RETURN_SUCCESS()/KAPI_RETURN_SUCCESS_RANGE() macros
+    /// only set the one that applies.
+    pub fn keep_used_success_fields(&mut self) {
+        match self.check_type {
+            0 => {
+                self.success_min = None;
+                self.success_max = None;
+            }
+            1 => self.success_value = None,
+            _ => {
+                self.success_value = None;
+                self.success_min = None;
+                self.success_max = None;
+            }
+        }
+    }
+}
+
+/// Error specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ErrorSpec {
+    pub error_code: i32,
+    pub name: String,
+    pub condition: String,
+    pub description: String,
+}
+
+/// Signal specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct SignalSpec {
+    pub signal_num: i32,
+    pub signal_name: String,
+    pub direction: u32,
+    pub action: u32,
+    pub target: Option<String>,
+    pub condition: Option<String>,
+    pub description: Option<String>,
+    pub timing: u32,
+    pub priority: u32,
+    pub restartable: bool,
+    pub interruptible: bool,
+    pub queue: Option<String>,
+    pub sa_flags: u32,
+    pub sa_flags_required: u32,
+    pub sa_flags_forbidden: u32,
+    pub state_required: u32,
+    pub state_forbidden: u32,
+    pub error_on_signal: Option<i32>,
+    /// Signal number to transform to (e.g. `SIGKILL` -> 9 on x86).
+    /// Always an integer or null in JSON -- the schema never widens to
+    /// a string. Extractors reading the compiled struct (`--vmlinux`,
+    /// `--debugfs`) populate this directly. The source-kerneldoc parser
+    /// populates it only when the `transform-to:` subfield is a numeric
+    /// literal; symbolic signal names are arch-dependent and cannot be
+    /// resolved portably in userspace, so they are reported via an
+    /// stderr warning and leave this field `None`. Consumers that need
+    /// the resolved number for a symbolic spec should use `--vmlinux`
+    /// or `--debugfs` against a kernel built for the target arch.
+    pub transform_to: Option<i32>,
+}
+
+/// Signal mask specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct SignalMaskSpec {
+    pub name: String,
+    pub description: String,
+    pub signals: Vec<i32>,
+}
+
+/// Side effect specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct SideEffectSpec {
+    pub effect_type: u32,
+    pub target: String,
+    pub condition: Option<String>,
+    pub description: String,
+    pub reversible: bool,
+}
+
+/// State transition specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct StateTransitionSpec {
+    pub object: String,
+    pub from_state: String,
+    pub to_state: String,
+    pub condition: Option<String>,
+    pub description: String,
+}
+
+/// Constraint specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ConstraintSpec {
+    pub name: String,
+    pub description: String,
+    pub expression: Option<String>,
+}
+
+/// Lock scope enum values matching kernel enum kapi_lock_scope
+pub const KAPI_LOCK_INTERNAL: u32 = 0;
+pub const KAPI_LOCK_ACQUIRES: u32 = 1;
+pub const KAPI_LOCK_RELEASES: u32 = 2;
+pub const KAPI_LOCK_CALLER_HELD: u32 = 3;
+
+/// Lock specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct LockSpec {
+    pub lock_name: String,
+    pub lock_type: u32,
+    pub scope: u32,
+    pub description: String,
+}
+
+/// Struct field specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct StructFieldSpec {
+    pub name: String,
+    pub field_type: u32,
+    pub type_name: String,
+    pub offset: usize,
+    pub size: usize,
+    pub flags: u32,
+    pub constraint_type: u32,
+    pub min_value: i64,
+    pub max_value: i64,
+    pub valid_mask: u64,
+    pub description: String,
+}
+
+/// Struct specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct StructSpec {
+    pub name: String,
+    pub size: usize,
+    pub alignment: usize,
+    pub field_count: u32,
+    pub fields: Vec<StructFieldSpec>,
+    pub description: String,
+}
+
+/// Common API specification information that all extractors should provide
+#[derive(Debug, Clone, Default)]
+pub struct ApiSpec {
+    pub name: String,
+    pub api_type: String,
+    pub description: Option<String>,
+    pub long_description: Option<String>,
+    pub version: Option<String>,
+    pub context_flags: Vec<String>,
+    pub param_count: Option<u32>,
+    pub error_count: Option<u32>,
+    pub examples: Option<String>,
+    pub notes: Option<String>,
+    // Sysfs-specific fields
+    pub subsystem: Option<String>,
+    pub sysfs_path: Option<String>,
+    pub permissions: Option<String>,
+    pub capabilities: Vec<CapabilitySpec>,
+    pub parameters: Vec<ParamSpec>,
+    pub return_spec: Option<ReturnSpec>,
+    pub errors: Vec<ErrorSpec>,
+    pub signals: Vec<SignalSpec>,
+    pub signal_masks: Vec<SignalMaskSpec>,
+    pub side_effects: Vec<SideEffectSpec>,
+    pub state_transitions: Vec<StateTransitionSpec>,
+    pub constraints: Vec<ConstraintSpec>,
+    pub locks: Vec<LockSpec>,
+    pub struct_specs: Vec<StructSpec>,
+}
+
+/// Trait for extracting API specifications from different sources
+pub trait ApiExtractor {
+    /// Extract all API specifications from the source
+    fn extract_all(&self) -> Result<Vec<ApiSpec>>;
+
+    /// Extract a specific API specification by name
+    fn extract_by_name(&self, name: &str) -> Result<Option<ApiSpec>>;
+
+    /// Display detailed information about a specific API
+    fn display_api_details(
+        &self,
+        api_name: &str,
+        formatter: &mut dyn OutputFormatter,
+        writer: &mut dyn Write,
+    ) -> Result<()>;
+}
+
+/// Helper function to display an ApiSpec using a formatter
+pub fn display_api_spec(
+    spec: &ApiSpec,
+    formatter: &mut dyn OutputFormatter,
+    writer: &mut dyn Write,
+) -> Result<()> {
+    formatter.begin_api_details(writer, &spec.name)?;
+
+    if let Some(desc) = &spec.description {
+        formatter.description(writer, desc)?;
+    }
+
+    if let Some(long_desc) = &spec.long_description {
+        formatter.long_description(writer, long_desc)?;
+    }
+
+    if !spec.context_flags.is_empty() {
+        formatter.begin_context_flags(writer)?;
+        for flag in &spec.context_flags {
+            formatter.context_flag(writer, flag)?;
+        }
+        formatter.end_context_flags(writer)?;
+    }
+
+    if !spec.parameters.is_empty() {
+        formatter.begin_parameters(writer, spec.parameters.len().try_into().unwrap_or(u32::MAX))?;
+        for param in &spec.parameters {
+            formatter.parameter(writer, param)?;
+        }
+        formatter.end_parameters(writer)?;
+    }
+
+    if let Some(ret) = &spec.return_spec {
+        formatter.return_spec(writer, ret)?;
+    }
+
+    if !spec.errors.is_empty() {
+        formatter.begin_errors(writer, spec.errors.len().try_into().unwrap_or(u32::MAX))?;
+        for error in &spec.errors {
+            formatter.error(writer, error)?;
+        }
+        formatter.end_errors(writer)?;
+    }
+
+    if let Some(notes) = &spec.notes {
+        formatter.notes(writer, notes)?;
+    }
+
+    if let Some(examples) = &spec.examples {
+        formatter.examples(writer, examples)?;
+    }
+
+    // Display sysfs-specific fields
+    if spec.api_type == "sysfs" {
+        if let Some(subsystem) = &spec.subsystem {
+            formatter.sysfs_subsystem(writer, subsystem)?;
+        }
+        if let Some(path) = &spec.sysfs_path {
+            formatter.sysfs_path(writer, path)?;
+        }
+        if let Some(perms) = &spec.permissions {
+            formatter.sysfs_permissions(writer, perms)?;
+        }
+    }
+
+    if !spec.capabilities.is_empty() {
+        formatter.begin_capabilities(writer)?;
+        for cap in &spec.capabilities {
+            formatter.capability(writer, cap)?;
+        }
+        formatter.end_capabilities(writer)?;
+    }
+
+    // Display signals
+    if !spec.signals.is_empty() {
+        formatter.begin_signals(writer, spec.signals.len().try_into().unwrap_or(u32::MAX))?;
+        for signal in &spec.signals {
+            formatter.signal(writer, signal)?;
+        }
+        formatter.end_signals(writer)?;
+    }
+
+    // Display signal masks
+    if !spec.signal_masks.is_empty() {
+        formatter.begin_signal_masks(
+            writer,
+            spec.signal_masks.len().try_into().unwrap_or(u32::MAX),
+        )?;
+        for mask in &spec.signal_masks {
+            formatter.signal_mask(writer, mask)?;
+        }
+        formatter.end_signal_masks(writer)?;
+    }
+
+    // Display side effects
+    if !spec.side_effects.is_empty() {
+        formatter.begin_side_effects(
+            writer,
+            spec.side_effects.len().try_into().unwrap_or(u32::MAX),
+        )?;
+        for effect in &spec.side_effects {
+            formatter.side_effect(writer, effect)?;
+        }
+        formatter.end_side_effects(writer)?;
+    }
+
+    // Display state transitions
+    if !spec.state_transitions.is_empty() {
+        formatter.begin_state_transitions(
+            writer,
+            spec.state_transitions.len().try_into().unwrap_or(u32::MAX),
+        )?;
+        for trans in &spec.state_transitions {
+            formatter.state_transition(writer, trans)?;
+        }
+        formatter.end_state_transitions(writer)?;
+    }
+
+    // Display constraints
+    if !spec.constraints.is_empty() {
+        formatter.begin_constraints(
+            writer,
+            spec.constraints.len().try_into().unwrap_or(u32::MAX),
+        )?;
+        for constraint in &spec.constraints {
+            formatter.constraint(writer, constraint)?;
+        }
+        formatter.end_constraints(writer)?;
+    }
+
+    // Display locks
+    if !spec.locks.is_empty() {
+        formatter.begin_locks(writer, spec.locks.len().try_into().unwrap_or(u32::MAX))?;
+        for lock in &spec.locks {
+            formatter.lock(writer, lock)?;
+        }
+        formatter.end_locks(writer)?;
+    }
+
+    // Display struct specs
+    if !spec.struct_specs.is_empty() {
+        formatter.begin_struct_specs(
+            writer,
+            spec.struct_specs.len().try_into().unwrap_or(u32::MAX),
+        )?;
+        for struct_spec in &spec.struct_specs {
+            formatter.struct_spec(writer, struct_spec)?;
+        }
+        formatter.end_struct_specs(writer)?;
+    }
+
+    formatter.end_api_details(writer)?;
+
+    Ok(())
+}
diff --git a/tools/kapi/src/extractor/source_parser.rs b/tools/kapi/src/extractor/source_parser.rs
new file mode 100644
index 0000000000000..cc50e15f16c83
--- /dev/null
+++ b/tools/kapi/src/extractor/source_parser.rs
@@ -0,0 +1,531 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::kerneldoc_parser::KerneldocParser;
+use super::{display_api_spec, ApiExtractor, ApiSpec};
+use crate::formatter::OutputFormatter;
+use anyhow::{bail, Context, Result};
+use regex::Regex;
+use std::fs;
+use std::io::Write;
+use std::path::Path;
+use walkdir::WalkDir;
+
+/// Extractor for kernel source files with KAPI-annotated kerneldoc
+pub struct SourceExtractor {
+    path: String,
+    parser: KerneldocParser,
+    syscall_regex: Regex,
+    ioctl_regex: Regex,
+    function_regex: Regex,
+}
+
+impl SourceExtractor {
+    pub fn new(path: &str) -> Result<Self> {
+        if !Path::new(path).exists() {
+            bail!("Source path does not exist: {path}");
+        }
+
+        Ok(SourceExtractor {
+            path: path.to_string(),
+            parser: KerneldocParser::new(),
+            syscall_regex: Regex::new(r"SYSCALL_DEFINE\d+\((\w+)")?,
+            ioctl_regex: Regex::new(r"(?:static\s+)?long\s+(\w+_ioctl)\s*\(")?,
+            function_regex: Regex::new(concat!(
+                r"(?m)^(?:static\s+)?(?:inline\s+)?",
+                r"(?:(?:unsigned\s+)?",
+                r"(?:long|int|void|char|short",
+                r"|struct\s+\w+\s*\*?",
+                r"|[\w_]+_t)",
+                r"\s*\*?\s+)?",
+                r"(\w+)\s*\([^)]*\)",
+            ))?,
+        })
+    }
+
+    fn extract_from_file(&self, path: &Path) -> Result<Vec<ApiSpec>> {
+        let content = fs::read_to_string(path)
+            .with_context(|| format!("Failed to read file: {}", path.display()))?;
+
+        self.extract_from_content(&content)
+    }
+
+    /// Same rule as has-apispec in scripts/Makefile.build: a contexts: (or
+    /// context-flags:) line plus one more KAPI section.
+    fn has_context_line(doc: &str) -> bool {
+        const SECTIONS: &[&str] = &[
+            "api-type:",
+            "param:",
+            "error:",
+            "capability:",
+            "signal:",
+            "lock:",
+            "state-trans:",
+            "constraint:",
+            "side-effect:",
+            "long-desc:",
+        ];
+        let has_line = |keys: &[&str]| {
+            doc.lines()
+                .map(str::trim_start)
+                .any(|l| keys.iter().any(|k| l.starts_with(k)))
+        };
+
+        has_line(&["contexts:", "context-flags:"]) && has_line(SECTIONS)
+    }
+
+    fn extract_from_content(&self, content: &str) -> Result<Vec<ApiSpec>> {
+        let mut specs = Vec::new();
+        let mut in_kerneldoc = false;
+        let mut current_doc = String::new();
+        let lines: Vec<&str> = content.lines().collect();
+        let mut i = 0;
+
+        while i < lines.len() {
+            let line = lines[i];
+
+            // Start of kerneldoc comment
+            if line.trim_start().starts_with("/**") {
+                in_kerneldoc = true;
+                current_doc.clear();
+                i += 1;
+                continue;
+            }
+
+            // Inside kerneldoc comment
+            if in_kerneldoc {
+                if line.contains("*/") {
+                    in_kerneldoc = false;
+
+                    // Check if this kerneldoc has KAPI annotations
+                    if Self::has_context_line(&current_doc) {
+                        // Look ahead for the function declaration
+                        if let Some((name, api_type, signature)) =
+                            self.find_function_after(&lines, i + 1)
+                        {
+                            if let Ok(spec) = self.parser.parse_kerneldoc(
+                                &current_doc,
+                                &name,
+                                &api_type,
+                                Some(&signature),
+                            ) {
+                                specs.push(spec);
+                            }
+                        }
+                    }
+                } else {
+                    // Remove leading asterisk and preserve content
+                    let cleaned = if let Some(stripped) = line.trim_start().strip_prefix("*") {
+                        if let Some(no_space) = stripped.strip_prefix(' ') {
+                            no_space
+                        } else {
+                            stripped
+                        }
+                    } else {
+                        line.trim_start()
+                    };
+                    current_doc.push_str(cleaned);
+                    current_doc.push('\n');
+                }
+            }
+
+            i += 1;
+        }
+
+        Ok(specs)
+    }
+
+    fn find_function_after(
+        &self,
+        lines: &[&str],
+        start: usize,
+    ) -> Option<(String, String, String)> {
+        for i in start..lines.len().min(start + 10) {
+            let line = lines[i];
+
+            // Skip blank lines and a plain comment before the function
+            let trimmed = line.trim_start();
+            if trimmed.is_empty() || trimmed.starts_with("/*") || trimmed.starts_with('*') {
+                continue;
+            }
+
+            // Check for SYSCALL_DEFINE
+            if let Some(caps) = self.syscall_regex.captures(line) {
+                let name = format!("sys_{}", caps.get(1).unwrap().as_str());
+                let signature = self.extract_syscall_signature(lines, i);
+                return Some((name, "syscall".to_string(), signature));
+            }
+
+            // Check for ioctl function
+            if let Some(caps) = self.ioctl_regex.captures(line) {
+                let name = caps.get(1).unwrap().as_str().to_string();
+                return Some((name, "ioctl".to_string(), line.to_string()));
+            }
+
+            // Check for regular function
+            if let Some(caps) = self.function_regex.captures(line) {
+                let name = caps.get(1).unwrap().as_str().to_string();
+                return Some((name, "function".to_string(), line.to_string()));
+            }
+
+            // Stop if we hit something that's clearly not part of the function declaration
+            if !line.starts_with(' ') && !line.starts_with('\t') && !line.trim().is_empty() {
+                break;
+            }
+        }
+
+        None
+    }
+
+    fn extract_syscall_signature(&self, lines: &[&str], start: usize) -> String {
+        // Extract the full SYSCALL_DEFINE signature
+        let mut sig = String::new();
+        let mut in_paren = false;
+        let mut paren_count = 0;
+
+        for line in lines.iter().skip(start).take(20) {
+            let line = *line;
+
+            // Start of SYSCALL_DEFINE
+            if line.contains("SYSCALL_DEFINE") {
+                if let Some(pos) = line.find('(') {
+                    sig.push_str(&line[pos..]);
+                    in_paren = true;
+                    paren_count = line[pos..].chars().filter(|&c| c == '(').count()
+                        - line[pos..].chars().filter(|&c| c == ')').count();
+                }
+            } else if in_paren {
+                sig.push(' ');
+                sig.push_str(line.trim());
+                paren_count += line.chars().filter(|&c| c == '(').count();
+                paren_count =
+                    paren_count.saturating_sub(line.chars().filter(|&c| c == ')').count());
+
+                if paren_count == 0 {
+                    break;
+                }
+            }
+        }
+
+        sig
+    }
+}
+
+impl ApiExtractor for SourceExtractor {
+    fn extract_all(&self) -> Result<Vec<ApiSpec>> {
+        let path = Path::new(&self.path);
+        let mut all_specs = Vec::new();
+
+        if path.is_file() {
+            // Single file
+            all_specs.extend(self.extract_from_file(path)?);
+        } else if path.is_dir() {
+            // Directory - walk all .c files
+            for entry in WalkDir::new(path)
+                .into_iter()
+                .filter_map(|e| e.ok())
+                .filter(|e| {
+                    e.path()
+                        .extension()
+                        .is_some_and(|ext| ext == "c" || ext == "h")
+                })
+            {
+                match self.extract_from_file(entry.path()) {
+                    Ok(specs) => all_specs.extend(specs),
+                    Err(e) => {
+                        eprintln!("Warning: failed to parse {}: {}", entry.path().display(), e);
+                    }
+                }
+            }
+        }
+
+        Ok(all_specs)
+    }
+
+    fn extract_by_name(&self, name: &str) -> Result<Option<ApiSpec>> {
+        let all_specs = self.extract_all()?;
+        Ok(all_specs.into_iter().find(|s| s.name == name))
+    }
+
+    fn display_api_details(
+        &self,
+        api_name: &str,
+        formatter: &mut dyn OutputFormatter,
+        output: &mut dyn Write,
+    ) -> Result<()> {
+        if let Some(spec) = self.extract_by_name(api_name)? {
+            display_api_spec(&spec, formatter, output)?;
+        } else {
+            writeln!(output, "API '{}' not found", api_name)?;
+        }
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn make_extractor() -> SourceExtractor {
+        SourceExtractor::new("/dev/null").unwrap()
+    }
+
+    #[test]
+    fn detect_contexts_spec() {
+        let content = r#"
+/**
+ * sys_foo - do foo
+ * @arg: the argument
+ *
+ * contexts: process, sleepable
+ *
+ * long-desc: Does foo.
+ */
+SYSCALL_DEFINE1(foo, int, arg)
+{
+    return 0;
+}
+
+/**
+ * bar - not a spec, even though the prose mentions context: here
+ * @x: value
+ */
+SYSCALL_DEFINE1(bar, int, x)
+{
+    return 0;
+}
+
+/**
+ * baz - not a spec either
+ * @x: value
+ *
+ * contexts: task, softirq, hardirq, nmi.
+ */
+SYSCALL_DEFINE1(baz, int, x)
+{
+    return 0;
+}
+"#;
+        let specs = make_extractor().extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 1);
+        assert_eq!(specs[0].name, "sys_foo");
+    }
+
+    #[test]
+    fn prose_mentioning_a_section_key_is_not_a_spec() {
+        let content = r#"
+/**
+ * __probe - check a device
+ * @drv: driver
+ *
+ * returns 0 on success, else error.
+ * side-effect: dev->driver is set to drv when drv claims dev.
+ */
+static int __probe(struct driver *drv, struct device *dev)
+{
+    return 0;
+}
+"#;
+        let specs = make_extractor().extract_from_content(content).unwrap();
+        assert!(specs.is_empty());
+    }
+
+    #[test]
+    fn nonexistent_path_is_rejected() {
+        let err = SourceExtractor::new("/nonexistent/kapi-test")
+            .err()
+            .unwrap();
+
+        assert!(err.to_string().contains("does not exist"), "{err}");
+    }
+
+    #[test]
+    fn has_context_line_needs_two_sections() {
+        let rule = SourceExtractor::has_context_line;
+
+        assert!(rule("contexts: process\nlong-desc: foo\n"));
+        assert!(rule("context-flags: KAPI_CTX_PROCESS\n  lock: foo\n"));
+        assert!(rule("contexts: process\nparam: fd\n"));
+        assert!(rule("contexts: process\nerror: EINVAL, bad fd\n"));
+        assert!(!rule("contexts: process\n"));
+        assert!(!rule("context: process\nlong-desc: foo\n"));
+        assert!(!rule("long-desc: foo\nside-effect: bar\n"));
+    }
+
+    #[test]
+    fn detect_syscall_define3() {
+        let content = r#"
+/**
+ * sys_open - open a file
+ * context-flags: KAPI_CTX_PROCESS
+ * param-count: 3
+ * @filename: pathname to open
+ * param: filename
+ * error: ENOENT, test
+ */
+SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
+{
+    return 0;
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 1);
+        assert_eq!(specs[0].name, "sys_open");
+        assert_eq!(specs[0].api_type, "syscall");
+    }
+
+    #[test]
+    fn detect_syscall_define1() {
+        let content = r#"
+/**
+ * sys_close - close a file descriptor
+ * context-flags: KAPI_CTX_PROCESS
+ * @fd: file descriptor to close
+ * error: EBADF, test
+ */
+SYSCALL_DEFINE1(close, unsigned int, fd)
+{
+    return 0;
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 1);
+        assert_eq!(specs[0].name, "sys_close");
+    }
+
+    #[test]
+    fn detect_syscall_define6() {
+        let content = r#"
+/**
+ * sys_mmap - map memory
+ * context-flags: KAPI_CTX_PROCESS
+ * error: ENOMEM, test
+ */
+SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len, unsigned long, prot,
+    unsigned long, flags, unsigned long, fd, unsigned long, offset)
+{
+    return 0;
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 1);
+        assert_eq!(specs[0].name, "sys_mmap");
+    }
+
+    #[test]
+    fn detect_ioctl_pattern() {
+        let content = r#"
+/**
+ * my_ioctl - handle ioctl
+ * context-flags: KAPI_CTX_PROCESS
+ * error: EINVAL, test
+ */
+static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
+{
+    return 0;
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 1);
+        assert_eq!(specs[0].name, "my_ioctl");
+        assert_eq!(specs[0].api_type, "ioctl");
+    }
+
+    #[test]
+    fn find_function_after_skips_blanks() {
+        // Test that find_function_after looks past blank lines
+        let lines = vec!["", "", "SYSCALL_DEFINE2(foo, int, bar, int, baz)", "{"];
+        let ext = make_extractor();
+        let result = ext.find_function_after(&lines, 0);
+        assert!(result.is_some());
+        let (name, api_type, _sig) = result.unwrap();
+        assert_eq!(name, "sys_foo");
+        assert_eq!(api_type, "syscall");
+    }
+
+    #[test]
+    fn find_function_after_skips_plain_comment() {
+        let lines = vec![
+            "/*",
+            " * Careful here!",
+            " */",
+            "SYSCALL_DEFINE1(close, unsigned int, fd)",
+            "{",
+        ];
+        let ext = make_extractor();
+        let (name, api_type, _sig) = ext.find_function_after(&lines, 0).unwrap();
+        assert_eq!(name, "sys_close");
+        assert_eq!(api_type, "syscall");
+    }
+
+    #[test]
+    fn find_function_after_returns_none_for_no_match() {
+        // No function declaration within lookahead range
+        let lines = vec!["#include <linux/fs.h>", "#define FOO 1", "/* comment */"];
+        let ext = make_extractor();
+        let result = ext.find_function_after(&lines, 0);
+        assert!(result.is_none());
+    }
+
+    #[test]
+    fn find_function_after_detects_regular_function() {
+        let lines = vec!["", "int do_something(struct task_struct *task)", "{"];
+        let ext = make_extractor();
+        let result = ext.find_function_after(&lines, 0);
+        assert!(result.is_some());
+        let (name, api_type, _) = result.unwrap();
+        assert_eq!(name, "do_something");
+        assert_eq!(api_type, "function");
+    }
+
+    #[test]
+    fn no_kapi_annotations_produces_empty() {
+        // kerneldoc without any KAPI annotations should not produce a spec
+        let content = r#"
+/**
+ * my_func - does stuff
+ * @arg: an argument
+ */
+void my_func(int arg)
+{
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert!(specs.is_empty());
+    }
+
+    #[test]
+    fn multiple_syscalls_in_one_file() {
+        let content = r#"
+/**
+ * sys_read - read from fd
+ * context-flags: KAPI_CTX_PROCESS
+ * error: EBADF, test
+ */
+SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
+{
+    return 0;
+}
+
+/**
+ * sys_write - write to fd
+ * context-flags: KAPI_CTX_PROCESS
+ * error: EBADF, test
+ */
+SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)
+{
+    return 0;
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 2);
+        assert_eq!(specs[0].name, "sys_read");
+        assert_eq!(specs[1].name, "sys_write");
+    }
+}
diff --git a/tools/kapi/src/extractor/vmlinux/binary_utils.rs b/tools/kapi/src/extractor/vmlinux/binary_utils.rs
new file mode 100644
index 0000000000000..046772ea690c1
--- /dev/null
+++ b/tools/kapi/src/extractor/vmlinux/binary_utils.rs
@@ -0,0 +1,461 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+// Array-bound constants matching `include/linux/kernel_api_spec.h`.
+// String fields are `const char *`; call `DataReader::ptr_size()` for
+// the per-target pointer width.
+pub mod sizes {
+    pub const MAX_PARAMS: usize = 16;
+    pub const MAX_ERRORS: usize = 32;
+    pub const MAX_CONSTRAINTS: usize = 32;
+    pub const MAX_LOCKS: usize = 16;
+    pub const MAX_CAPABILITIES: usize = 8;
+    pub const MAX_SIGNALS: usize = 32;
+    pub const MAX_STRUCT_SPECS: usize = 8;
+    pub const MAX_SIDE_EFFECTS: usize = 32;
+    pub const MAX_STATE_TRANS: usize = 8;
+
+    pub const NAME: usize = 0;
+    pub const DESC: usize = 0;
+}
+
+// Section markers of `struct kernel_api_spec`. The kernel only sets a
+// marker when the matching *_COUNT()/KAPI_EXAMPLES() macro is used, so
+// a zero marker is as valid as the expected one.
+pub mod magic {
+    pub const PARAMS: u32 = 0x4B415031; // 'KAP1'
+    pub const RETURN: u32 = 0x4B415232; // 'KAR2'
+    pub const ERRORS: u32 = 0x4B414533; // 'KAE3'
+    pub const LOCKS: u32 = 0x4B414C34; // 'KAL4'
+    pub const CONSTRAINTS: u32 = 0x4B414335; // 'KAC5'
+    pub const INFO: u32 = 0x4B414936; // 'KAI6'
+    pub const SIGNALS: u32 = 0x4B415337; // 'KAS7'
+    pub const SIGMASK: u32 = 0x4B414D38; // 'KAM8'
+    pub const STRUCTS: u32 = 0x4B415439; // 'KAT9'
+    pub const EFFECTS: u32 = 0x4B414641; // 'KAFA'
+    pub const TRANS: u32 = 0x4B415442; // 'KATB'
+    pub const CAPS: u32 = 0x4B414343; // 'KACC'
+}
+
+/// Resolve a virtual-address string pointer against the vmlinux ELF
+/// and return the NUL-terminated C string it points at.
+pub fn resolve_vaddr_string(elf: &goblin::elf::Elf, data: &[u8], vaddr: u64) -> Option<String> {
+    if vaddr == 0 {
+        return None;
+    }
+    for sh in &elf.section_headers {
+        let start = sh.sh_addr;
+        let end = start.checked_add(sh.sh_size)?;
+        if vaddr < start || vaddr >= end {
+            continue;
+        }
+        // File-backed sections only (skip SHT_NOBITS etc.)
+        if sh.sh_type == goblin::elf::section_header::SHT_NOBITS {
+            return None;
+        }
+        let rel = (vaddr - start) as usize;
+        let file_start = sh.sh_offset as usize + rel;
+        if file_start >= data.len() {
+            return None;
+        }
+        let tail = &data[file_start..];
+        let nul = tail.iter().position(|&b| b == 0)?;
+        return std::str::from_utf8(&tail[..nul]).ok().map(str::to_string);
+    }
+    None
+}
+
+/// Endianness of the target ELF binary
+#[derive(Clone, Copy, PartialEq)]
+pub enum Endian {
+    Little,
+    Big,
+}
+
+/// Resolves string pointers read from `.kapi_specs` back to their
+/// underlying C strings in the vmlinux rodata.
+pub struct StringResolver<'a> {
+    pub elf: &'a goblin::elf::Elf<'a>,
+    pub vmlinux: &'a [u8],
+}
+
+// Helper for reading data at specific offsets
+pub struct DataReader<'a> {
+    pub data: &'a [u8],
+    pub pos: usize,
+    pub endian: Endian,
+    /// true for 64-bit ELF, false for 32-bit
+    pub is_64bit: bool,
+    /// Used to follow `const char *` fields into rodata.
+    pub resolver: Option<StringResolver<'a>>,
+}
+
+impl<'a> DataReader<'a> {
+    pub fn new(data: &'a [u8], offset: usize, endian: Endian, is_64bit: bool) -> Self {
+        Self {
+            data,
+            pos: offset,
+            endian,
+            is_64bit,
+            resolver: None,
+        }
+    }
+
+    pub fn with_resolver(mut self, resolver: StringResolver<'a>) -> Self {
+        self.resolver = Some(resolver);
+        self
+    }
+
+    /// Pointer width of the target in bytes (4 or 8).
+    pub fn ptr_size(&self) -> usize {
+        if self.is_64bit {
+            8
+        } else {
+            4
+        }
+    }
+
+    /// Advance the read position to the next multiple of `align`.
+    /// Needed before every naturally-aligned field when the containing
+    /// struct is not `__packed`.
+    pub fn align_to(&mut self, align: usize) {
+        if align > 1 {
+            let rem = self.pos % align;
+            if rem != 0 {
+                self.pos = (self.pos + (align - rem)).min(self.data.len());
+            }
+        }
+    }
+
+    /// Read a target-sized pointer slot. Returns the virtual address
+    /// stored in the slot, or `None` if there isn't enough data. The
+    /// caller is expected to align the reader first if the containing
+    /// struct demands natural alignment.
+    pub fn read_ptr(&mut self) -> Option<u64> {
+        self.align_to(self.ptr_size());
+        if self.is_64bit {
+            self.read_u64()
+        } else {
+            self.read_u32().map(|v| v as u64)
+        }
+    }
+
+    pub fn read_bytes(&mut self, len: usize) -> Option<&'a [u8]> {
+        if self.pos + len <= self.data.len() {
+            let bytes = &self.data[self.pos..self.pos + len];
+            self.pos += len;
+            Some(bytes)
+        } else {
+            None
+        }
+    }
+
+    pub fn read_u32(&mut self) -> Option<u32> {
+        self.align_to(4);
+        let b: [u8; 4] = self.read_bytes(4)?.try_into().unwrap();
+        Some(match self.endian {
+            Endian::Little => u32::from_le_bytes(b),
+            Endian::Big => u32::from_be_bytes(b),
+        })
+    }
+
+    pub fn read_u8(&mut self) -> Option<u8> {
+        self.read_bytes(1).map(|b| b[0])
+    }
+
+    pub fn read_i32(&mut self) -> Option<i32> {
+        self.align_to(4);
+        let b: [u8; 4] = self.read_bytes(4)?.try_into().unwrap();
+        Some(match self.endian {
+            Endian::Little => i32::from_le_bytes(b),
+            Endian::Big => i32::from_be_bytes(b),
+        })
+    }
+
+    pub fn read_u64(&mut self) -> Option<u64> {
+        self.align_to(8);
+        let b: [u8; 8] = self.read_bytes(8)?.try_into().unwrap();
+        Some(match self.endian {
+            Endian::Little => u64::from_le_bytes(b),
+            Endian::Big => u64::from_be_bytes(b),
+        })
+    }
+
+    pub fn read_i64(&mut self) -> Option<i64> {
+        self.align_to(8);
+        let b: [u8; 8] = self.read_bytes(8)?.try_into().unwrap();
+        Some(match self.endian {
+            Endian::Little => i64::from_le_bytes(b),
+            Endian::Big => i64::from_be_bytes(b),
+        })
+    }
+
+    /// Read a target-sized unsigned value (4 bytes for 32-bit, 8 bytes for 64-bit).
+    pub fn read_usize(&mut self) -> Option<usize> {
+        self.align_to(self.ptr_size());
+        if self.is_64bit {
+            // No double-align: read_u64 would re-align, but we just
+            // did that with ptr_size() which is 8 on 64-bit.
+            let b: [u8; 8] = self.read_bytes(8)?.try_into().unwrap();
+            Some(match self.endian {
+                Endian::Little => u64::from_le_bytes(b) as usize,
+                Endian::Big => u64::from_be_bytes(b) as usize,
+            })
+        } else {
+            let b: [u8; 4] = self.read_bytes(4)?.try_into().unwrap();
+            Some(match self.endian {
+                Endian::Little => u32::from_le_bytes(b) as usize,
+                Endian::Big => u32::from_be_bytes(b) as usize,
+            })
+        }
+    }
+
+    // Helper methods for common patterns
+    pub fn read_bool(&mut self) -> Option<bool> {
+        self.read_u8().map(|v| v != 0)
+    }
+
+    /// Read a `const char *` slot using the target pointer width
+    /// (4 bytes on 32-bit, 8 bytes on 64-bit) and, if a resolver is
+    /// attached, follow the address into the vmlinux to recover the
+    /// C string. The `_max_len` argument is ignored.
+    pub fn read_optional_string(&mut self, _max_len: usize) -> Option<String> {
+        let vaddr = self.read_ptr()?;
+        let resolver = self.resolver.as_ref()?;
+        resolve_vaddr_string(resolver.elf, resolver.vmlinux, vaddr).filter(|s| !s.is_empty())
+    }
+
+    pub fn read_string_or_default(&mut self, max_len: usize) -> String {
+        self.read_optional_string(max_len).unwrap_or_default()
+    }
+
+    /// Follow a `const s64 *` slot (already read with `read_ptr`) to the
+    /// `count` values it points at. A NULL or unresolvable pointer yields
+    /// an empty list.
+    pub fn resolve_s64_array(&self, vaddr: u64, count: u32) -> Vec<i64> {
+        let Some(resolver) = self.resolver.as_ref() else {
+            return Vec::new();
+        };
+        if vaddr == 0 {
+            return Vec::new();
+        }
+        let Some(start) = super::vaddr_to_file_offset(resolver.elf, vaddr) else {
+            return Vec::new();
+        };
+        let raw = (count as usize)
+            .checked_mul(8)
+            .and_then(|len| start.checked_add(len))
+            .and_then(|end| resolver.vmlinux.get(start..end));
+        raw.map_or_else(Vec::new, |raw| {
+            raw.chunks_exact(8)
+                .map(|chunk| {
+                    let b: [u8; 8] = chunk.try_into().unwrap();
+                    match self.endian {
+                        Endian::Little => i64::from_le_bytes(b),
+                        Endian::Big => i64::from_be_bytes(b),
+                    }
+                })
+                .collect()
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // ---- DataReader little-endian tests ----
+
+    #[test]
+    fn read_u32_little_endian() {
+        let data = [0x78, 0x56, 0x34, 0x12];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u32(), Some(0x12345678));
+    }
+
+    #[test]
+    fn read_u32_big_endian() {
+        let data = [0x12, 0x34, 0x56, 0x78];
+        let mut reader = DataReader::new(&data, 0, Endian::Big, true);
+        assert_eq!(reader.read_u32(), Some(0x12345678));
+    }
+
+    #[test]
+    fn read_u64_little_endian() {
+        let data = 0xDEADBEEFCAFEBABEu64.to_le_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u64(), Some(0xDEADBEEFCAFEBABE));
+    }
+
+    #[test]
+    fn read_u64_big_endian() {
+        let data = 0xDEADBEEFCAFEBABEu64.to_be_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Big, true);
+        assert_eq!(reader.read_u64(), Some(0xDEADBEEFCAFEBABE));
+    }
+
+    #[test]
+    fn read_i32_little_endian_negative() {
+        let val: i32 = -42;
+        let data = val.to_le_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_i32(), Some(-42));
+    }
+
+    #[test]
+    fn read_i32_big_endian_negative() {
+        let val: i32 = -1;
+        let data = val.to_be_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Big, true);
+        assert_eq!(reader.read_i32(), Some(-1));
+    }
+
+    #[test]
+    fn read_i64_little_endian() {
+        let val: i64 = -9999999999;
+        let data = val.to_le_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_i64(), Some(-9999999999));
+    }
+
+    #[test]
+    fn read_i64_big_endian() {
+        let val: i64 = i64::MIN;
+        let data = val.to_be_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Big, true);
+        assert_eq!(reader.read_i64(), Some(i64::MIN));
+    }
+
+    // ---- read_usize tests ----
+
+    #[test]
+    fn read_usize_64bit() {
+        let val: u64 = 0x00000000FFFFFFFF;
+        let data = val.to_le_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_usize(), Some(0xFFFFFFFF));
+    }
+
+    #[test]
+    fn read_usize_32bit() {
+        let val: u32 = 0xABCD1234;
+        let data = val.to_le_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Little, false);
+        assert_eq!(reader.read_usize(), Some(0xABCD1234));
+    }
+
+    #[test]
+    fn read_usize_32bit_does_not_consume_8_bytes() {
+        // In 32-bit mode, read_usize should only consume 4 bytes
+        let mut data = [0u8; 8];
+        data[..4].copy_from_slice(&42u32.to_le_bytes());
+        data[4..8].copy_from_slice(&99u32.to_le_bytes());
+        let mut reader = DataReader::new(&data, 0, Endian::Little, false);
+        assert_eq!(reader.read_usize(), Some(42));
+        // After reading 4 bytes, pos should be at 4
+        assert_eq!(reader.pos, 4);
+        assert_eq!(reader.read_usize(), Some(99));
+    }
+
+    // ---- Bounds checking ----
+
+    #[test]
+    fn read_u32_past_end_returns_none() {
+        let data = [0x01, 0x02, 0x03]; // only 3 bytes, need 4
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u32(), None);
+    }
+
+    #[test]
+    fn read_u64_past_end_returns_none() {
+        let data = [0u8; 7]; // only 7 bytes, need 8
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u64(), None);
+    }
+
+    #[test]
+    fn read_bytes_past_end_returns_none() {
+        let data = [0u8; 4];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_bytes(5), None);
+    }
+
+    #[test]
+    fn read_at_offset() {
+        // read_u32 auto-aligns to a 4-byte boundary, so the starting
+        // offset must itself be 4-aligned for the value to be read
+        // from its declared position.
+        let data = [0xFF, 0xFF, 0xFF, 0xFF, 0x78, 0x56, 0x34, 0x12];
+        let mut reader = DataReader::new(&data, 4, Endian::Little, true);
+        assert_eq!(reader.read_u32(), Some(0x12345678));
+    }
+
+    #[test]
+    fn read_u32_auto_aligns() {
+        // Starting mid-word, read_u32 snaps to the next 4-byte boundary.
+        let data = [0xDE, 0xAD, 0xBE, 0xEF, 0x78, 0x56, 0x34, 0x12];
+        let mut reader = DataReader::new(&data, 1, Endian::Little, true);
+        assert_eq!(reader.read_u32(), Some(0x12345678));
+        assert_eq!(reader.pos, 8);
+    }
+
+    #[test]
+    fn read_ptr_32bit_uses_4_bytes() {
+        let data = [0x78, 0x56, 0x34, 0x12];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, false);
+        assert_eq!(reader.read_ptr(), Some(0x12345678));
+        assert_eq!(reader.pos, 4);
+    }
+
+    #[test]
+    fn read_ptr_64bit_uses_8_bytes() {
+        let data = [0x78, 0x56, 0x34, 0x12, 0x00, 0x00, 0x00, 0x00];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_ptr(), Some(0x12345678));
+        assert_eq!(reader.pos, 8);
+    }
+
+    #[test]
+    fn read_bool_values() {
+        let data = [0, 1, 255];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_bool(), Some(false));
+        assert_eq!(reader.read_bool(), Some(true));
+        assert_eq!(reader.read_bool(), Some(true)); // any non-zero is true
+    }
+
+    #[test]
+    fn sequential_reads_advance_position() {
+        let mut data = [0u8; 12];
+        data[..4].copy_from_slice(&1u32.to_le_bytes());
+        data[4..8].copy_from_slice(&2u32.to_le_bytes());
+        data[8..12].copy_from_slice(&3u32.to_le_bytes());
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u32(), Some(1));
+        assert_eq!(reader.read_u32(), Some(2));
+        assert_eq!(reader.read_u32(), Some(3));
+        assert_eq!(reader.pos, 12);
+    }
+
+    #[test]
+    fn read_optional_string_empty_returns_none() {
+        // A string buffer that is just NUL
+        let data = [0u8; 10];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_optional_string(10), None);
+    }
+
+    #[test]
+    fn read_string_or_default_with_empty() {
+        let data = [0u8; 10];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_string_or_default(10), "");
+    }
+
+    #[test]
+    fn read_u8_value() {
+        let data = [0x42];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u8(), Some(0x42));
+    }
+}
diff --git a/tools/kapi/src/extractor/vmlinux/mod.rs b/tools/kapi/src/extractor/vmlinux/mod.rs
new file mode 100644
index 0000000000000..5202f7d80c8d8
--- /dev/null
+++ b/tools/kapi/src/extractor/vmlinux/mod.rs
@@ -0,0 +1,1160 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::{
+    ApiExtractor, ApiSpec, CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec,
+    ReturnSpec, SideEffectSpec, SignalMaskSpec, SignalSpec, StateTransitionSpec, StructFieldSpec,
+    StructSpec,
+};
+use crate::formatter::OutputFormatter;
+use anyhow::{Context, Result};
+use goblin::elf::Elf;
+use std::fs;
+use std::io::Write;
+
+mod binary_utils;
+use binary_utils::{magic, sizes, DataReader, Endian};
+
+// Helper to convert empty strings to None
+fn opt_string(s: String) -> Option<String> {
+    if s.is_empty() {
+        None
+    } else {
+        Some(s)
+    }
+}
+
+pub struct VmlinuxExtractor {
+    vmlinux: Vec<u8>,
+    specs: Vec<KapiSpec>,
+    endian: Endian,
+    is_64bit: bool,
+}
+
+#[derive(Debug)]
+struct KapiSpec {
+    name: String,
+    api_type: String,
+    /// File offset in the vmlinux buffer where this spec's
+    /// `struct kernel_api_spec` begins.
+    file_offset: usize,
+}
+
+impl VmlinuxExtractor {
+    pub fn new(vmlinux_path: &str) -> Result<Self> {
+        let vmlinux = fs::read(vmlinux_path)
+            .with_context(|| format!("Failed to read vmlinux file: {vmlinux_path}"))?;
+
+        let elf = Elf::parse(&vmlinux).context("Failed to parse ELF file")?;
+        let endian = if elf.little_endian {
+            Endian::Little
+        } else {
+            Endian::Big
+        };
+        let is_64bit = elf.is_64;
+
+        // Locate the .kapi_specs section boundaries.
+        let mut start_addr = None;
+        let mut stop_addr = None;
+        for sym in &elf.syms {
+            if let Some(name) = elf.strtab.get_at(sym.st_name) {
+                match name {
+                    "__start_kapi_specs" => start_addr = Some(sym.st_value),
+                    "__stop_kapi_specs" => stop_addr = Some(sym.st_value),
+                    _ => {}
+                }
+            }
+        }
+        let start = start_addr.context("Could not find __start_kapi_specs symbol")?;
+        let stop = stop_addr.context("Could not find __stop_kapi_specs symbol")?;
+        if stop <= start {
+            anyhow::bail!("No kernel API specifications found in vmlinux");
+        }
+
+        // `.kapi_specs` is a tightly-packed array of `struct kernel_api_spec *`
+        // pointers; walk them to find each real spec's vaddr, then resolve to
+        // a file offset inside `vmlinux`. Pointer width tracks the target
+        // (4 bytes for 32-bit, 8 bytes for 64-bit).
+        let ptr_size = if is_64bit { 8usize } else { 4 };
+        let ptr_count = ((stop - start) as usize) / ptr_size;
+        let ptr_file_off =
+            vaddr_to_file_offset(&elf, start).context("Could not locate .kapi_specs in file")?;
+
+        let read_ptr = |raw: &[u8]| -> u64 {
+            match (endian, is_64bit) {
+                (Endian::Little, true) => u64::from_le_bytes(raw.try_into().unwrap()),
+                (Endian::Big, true) => u64::from_be_bytes(raw.try_into().unwrap()),
+                (Endian::Little, false) => u32::from_le_bytes(raw.try_into().unwrap()) as u64,
+                (Endian::Big, false) => u32::from_be_bytes(raw.try_into().unwrap()) as u64,
+            }
+        };
+
+        let mut specs = Vec::with_capacity(ptr_count);
+        for i in 0..ptr_count {
+            let p = ptr_file_off + i * ptr_size;
+            if p + ptr_size > vmlinux.len() {
+                break;
+            }
+            let spec_vaddr = read_ptr(&vmlinux[p..p + ptr_size]);
+            if spec_vaddr == 0 {
+                continue;
+            }
+            let Some(spec_file_off) = vaddr_to_file_offset(&elf, spec_vaddr) else {
+                continue;
+            };
+            // The first field of `struct kernel_api_spec` is `const char *name`.
+            if spec_file_off + ptr_size > vmlinux.len() {
+                continue;
+            }
+            let name_vaddr = read_ptr(&vmlinux[spec_file_off..spec_file_off + ptr_size]);
+            let name =
+                binary_utils::resolve_vaddr_string(&elf, &vmlinux, name_vaddr).unwrap_or_default();
+            if name.is_empty() {
+                continue;
+            }
+            let api_type = if name.starts_with("sys_") {
+                "syscall"
+            } else if name.ends_with("_ioctl") {
+                "ioctl"
+            } else {
+                "function"
+            }
+            .to_string();
+            specs.push(KapiSpec {
+                name,
+                api_type,
+                file_offset: spec_file_off,
+            });
+        }
+
+        Ok(VmlinuxExtractor {
+            vmlinux,
+            specs,
+            endian,
+            is_64bit,
+        })
+    }
+}
+
+/// Map a virtual address to a file offset inside the raw vmlinux bytes.
+fn vaddr_to_file_offset(elf: &Elf, vaddr: u64) -> Option<usize> {
+    for sh in &elf.section_headers {
+        let start = sh.sh_addr;
+        let end = start.checked_add(sh.sh_size)?;
+        if vaddr >= start && vaddr < end {
+            if sh.sh_type == goblin::elf::section_header::SHT_NOBITS {
+                return None;
+            }
+            return Some((sh.sh_offset + (vaddr - start)) as usize);
+        }
+    }
+    None
+}
+
+impl VmlinuxExtractor {
+    fn parse_at(&self, file_offset: usize) -> Result<ApiSpec> {
+        parse_binary_to_api_spec(&self.vmlinux, file_offset, self.endian, self.is_64bit)
+    }
+}
+
+impl ApiExtractor for VmlinuxExtractor {
+    fn extract_all(&self) -> Result<Vec<ApiSpec>> {
+        Ok(self
+            .specs
+            .iter()
+            .map(|spec| {
+                self.parse_at(spec.file_offset).unwrap_or_else(|e| {
+                    eprintln!("Warning: cannot parse the spec of {}: {e:#}", spec.name);
+                    ApiSpec {
+                        name: spec.name.clone(),
+                        api_type: spec.api_type.clone(),
+                        ..Default::default()
+                    }
+                })
+            })
+            .collect())
+    }
+
+    fn extract_by_name(&self, api_name: &str) -> Result<Option<ApiSpec>> {
+        if let Some(spec) = self.specs.iter().find(|s| s.name == api_name) {
+            Ok(Some(self.parse_at(spec.file_offset)?))
+        } else {
+            Ok(None)
+        }
+    }
+
+    fn display_api_details(
+        &self,
+        api_name: &str,
+        formatter: &mut dyn OutputFormatter,
+        writer: &mut dyn Write,
+    ) -> Result<()> {
+        if let Some(spec) = self.specs.iter().find(|s| s.name == api_name) {
+            let api_spec = self.parse_at(spec.file_offset)?;
+            super::display_api_spec(&api_spec, formatter, writer)?;
+        }
+        Ok(())
+    }
+}
+
+const TRUNCATED: &str = "kernel_api_spec runs past the end of the file";
+
+/// Consume a section marker. The kernel leaves it zero when the matching
+/// macro is not used, so only a different non-zero value means that the
+/// reader has lost sync with the struct layout.
+fn read_magic(reader: &mut DataReader, expected: u32) -> Result<()> {
+    let found = reader.read_u32().context(TRUNCATED)?;
+    if found != 0 && found != expected {
+        anyhow::bail!(
+            "unexpected section marker {found:#x} at offset {:#x}, expected {expected:#x}",
+            reader.pos - 4
+        );
+    }
+    Ok(())
+}
+
+/// Parse a `{ u32 magic; u32 count; T items[MAX]; }` section. Every one of
+/// the `max_items` slots is consumed, so the reader ends up behind the
+/// whole array; only the first `count` are returned.
+fn parse_array<T, F>(
+    reader: &mut DataReader,
+    expected_magic: u32,
+    max_items: usize,
+    parse_fn: F,
+) -> Result<Vec<T>>
+where
+    F: Fn(&mut DataReader, usize) -> Option<T>,
+{
+    read_magic(reader, expected_magic)?;
+    let count = reader.read_u32().context(TRUNCATED)? as usize;
+    let align = reader.ptr_size();
+    reader.align_to(align);
+
+    let mut items = Vec::new();
+    for i in 0..max_items {
+        let item = parse_fn(reader, i).context(TRUNCATED)?;
+        reader.align_to(align);
+        if i < count {
+            items.push(item);
+        }
+    }
+    Ok(items)
+}
+
+fn parse_binary_to_api_spec(
+    data: &[u8],
+    offset: usize,
+    endian: Endian,
+    is_64bit: bool,
+) -> Result<ApiSpec> {
+    let elf = Elf::parse(data).context("Failed to re-parse ELF for string resolution")?;
+    let resolver = binary_utils::StringResolver {
+        elf: &elf,
+        vmlinux: data,
+    };
+    let mut reader = DataReader::new(data, offset, endian, is_64bit).with_resolver(resolver);
+
+    // Read fields in exact order of struct kernel_api_spec.
+    // Every string field is a `const char *` pointer resolved via the
+    // StringResolver attached to the DataReader.
+    let name = reader
+        .read_optional_string(sizes::NAME)
+        .ok_or_else(|| anyhow::anyhow!("Failed to read API name"))?;
+
+    // Determine API type
+    let api_type = if name.starts_with("sys_") {
+        "syscall"
+    } else if name.ends_with("_ioctl") {
+        "ioctl"
+    } else if name.contains("sysfs") {
+        "sysfs"
+    } else {
+        "function"
+    }
+    .to_string();
+
+    let version = reader.read_u32().map(|v| v.to_string());
+
+    let description = reader
+        .read_optional_string(sizes::DESC)
+        .filter(|s| !s.is_empty());
+
+    let long_description = reader
+        .read_optional_string(sizes::DESC)
+        .filter(|s| !s.is_empty());
+
+    let context_flags = parse_context_flags(&mut reader);
+
+    let parameters = parse_array(&mut reader, magic::PARAMS, sizes::MAX_PARAMS, parse_param)?;
+
+    read_magic(&mut reader, magic::RETURN)?;
+    let return_spec = parse_return_spec(&mut reader);
+
+    let errors = parse_array(&mut reader, magic::ERRORS, sizes::MAX_ERRORS, |r, _| {
+        parse_error(r)
+    })?;
+
+    let locks = parse_array(&mut reader, magic::LOCKS, sizes::MAX_LOCKS, |r, _| {
+        parse_lock(r)
+    })?;
+
+    let constraints = parse_array(
+        &mut reader,
+        magic::CONSTRAINTS,
+        sizes::MAX_CONSTRAINTS,
+        |r, _| parse_constraint(r),
+    )?;
+
+    // Only KAPI_EXAMPLES() sets info_magic, so it says nothing about
+    // whether notes are present.
+    read_magic(&mut reader, magic::INFO)?;
+    let examples = reader
+        .read_optional_string(sizes::DESC)
+        .filter(|s| !s.is_empty());
+    let notes = reader
+        .read_optional_string(sizes::DESC)
+        .filter(|s| !s.is_empty());
+
+    let signals = parse_array(&mut reader, magic::SIGNALS, sizes::MAX_SIGNALS, |r, _| {
+        parse_signal(r)
+    })?;
+
+    let signal_masks = parse_array(&mut reader, magic::SIGMASK, sizes::MAX_SIGNALS, |r, _| {
+        parse_signal_mask(r)
+    })?;
+
+    let struct_specs = parse_array(
+        &mut reader,
+        magic::STRUCTS,
+        sizes::MAX_STRUCT_SPECS,
+        |r, _| parse_struct_spec(r),
+    )?;
+
+    let side_effects = parse_array(
+        &mut reader,
+        magic::EFFECTS,
+        sizes::MAX_SIDE_EFFECTS,
+        |r, _| parse_side_effect(r),
+    )?;
+
+    let state_transitions =
+        parse_array(&mut reader, magic::TRANS, sizes::MAX_STATE_TRANS, |r, _| {
+            parse_state_transition(r)
+        })?;
+
+    let capabilities = parse_array(&mut reader, magic::CAPS, sizes::MAX_CAPABILITIES, |r, _| {
+        parse_capability(r)
+    })?;
+
+    Ok(ApiSpec {
+        name,
+        api_type,
+        description,
+        long_description,
+        version,
+        context_flags,
+        param_count: if parameters.is_empty() {
+            None
+        } else {
+            Some(parameters.len() as u32)
+        },
+        error_count: if errors.is_empty() {
+            None
+        } else {
+            Some(errors.len() as u32)
+        },
+        examples,
+        notes,
+        subsystem: None,
+        sysfs_path: None,
+        permissions: None,
+        capabilities,
+        parameters,
+        return_spec,
+        errors,
+        signals,
+        signal_masks,
+        side_effects,
+        state_transitions,
+        constraints,
+        locks,
+        struct_specs,
+    })
+}
+
+// Helper parsing functions
+
+fn parse_context_flags(reader: &mut DataReader) -> Vec<String> {
+    const KAPI_CTX_PROCESS: u32 = 1 << 0;
+    const KAPI_CTX_SOFTIRQ: u32 = 1 << 1;
+    const KAPI_CTX_HARDIRQ: u32 = 1 << 2;
+    const KAPI_CTX_NMI: u32 = 1 << 3;
+    const KAPI_CTX_ATOMIC: u32 = 1 << 4;
+    const KAPI_CTX_SLEEPABLE: u32 = 1 << 5;
+    const KAPI_CTX_PREEMPT_DISABLED: u32 = 1 << 6;
+    const KAPI_CTX_IRQ_DISABLED: u32 = 1 << 7;
+
+    if let Some(flags) = reader.read_u32() {
+        let mut parts = Vec::new();
+
+        if flags & KAPI_CTX_PROCESS != 0 {
+            parts.push("KAPI_CTX_PROCESS");
+        }
+        if flags & KAPI_CTX_SOFTIRQ != 0 {
+            parts.push("KAPI_CTX_SOFTIRQ");
+        }
+        if flags & KAPI_CTX_HARDIRQ != 0 {
+            parts.push("KAPI_CTX_HARDIRQ");
+        }
+        if flags & KAPI_CTX_NMI != 0 {
+            parts.push("KAPI_CTX_NMI");
+        }
+        if flags & KAPI_CTX_ATOMIC != 0 {
+            parts.push("KAPI_CTX_ATOMIC");
+        }
+        if flags & KAPI_CTX_SLEEPABLE != 0 {
+            parts.push("KAPI_CTX_SLEEPABLE");
+        }
+        if flags & KAPI_CTX_PREEMPT_DISABLED != 0 {
+            parts.push("KAPI_CTX_PREEMPT_DISABLED");
+        }
+        if flags & KAPI_CTX_IRQ_DISABLED != 0 {
+            parts.push("KAPI_CTX_IRQ_DISABLED");
+        }
+
+        parts.into_iter().map(|s| s.to_string()).collect()
+    } else {
+        vec![]
+    }
+}
+
+fn parse_param(reader: &mut DataReader, index: usize) -> Option<ParamSpec> {
+    let name = reader.read_string_or_default(sizes::NAME);
+    let type_name = reader.read_string_or_default(sizes::NAME);
+    let param_type = reader.read_u32()?;
+    let flags = reader.read_u32()?;
+    let size = reader.read_usize()?;
+    let alignment = reader.read_usize()?;
+    let min_value = reader.read_i64()?;
+    let max_value = reader.read_i64()?;
+    let valid_mask = reader.read_u64()?;
+
+    let enum_values_ptr = reader.read_ptr()?;
+    let enum_count = reader.read_u32()?;
+    let constraint_type = reader.read_u32()?;
+    // Skip validate function pointer
+    reader.read_ptr()?;
+
+    let description = reader.read_string_or_default(sizes::DESC);
+    let constraint = reader.read_optional_string(sizes::DESC);
+    let size_param_idx_raw = reader.read_i32()?; // Must use ? to propagate errors
+    let _size_multiplier = reader.read_usize()?; // Must use ? to propagate errors
+
+    // In the C struct, size_param_idx is stored 1-based; 0 means
+    // "no size-carrying param". Surface the real (0-based) index as
+    // `Option<u32>`.
+    let size_param_idx = if size_param_idx_raw > 0 {
+        Some((size_param_idx_raw - 1) as u32)
+    } else {
+        None
+    };
+
+    let mut param = ParamSpec {
+        index: index as u32,
+        name,
+        type_name,
+        description,
+        flags,
+        param_type,
+        constraint_type,
+        constraint,
+        min_value: Some(min_value),
+        max_value: Some(max_value),
+        valid_mask: Some(valid_mask),
+        enum_values: reader
+            .resolve_s64_array(enum_values_ptr, enum_count)
+            .iter()
+            .map(i64::to_string)
+            .collect(),
+        size: Some(size as u32),
+        alignment: Some(alignment as u32),
+        size_param_idx,
+    };
+    param.keep_used_numbers();
+    Some(param)
+}
+
+fn parse_return_spec(reader: &mut DataReader) -> Option<ReturnSpec> {
+    // Read type_name, but treat empty as valid (will be empty string)
+    let type_name = reader.read_string_or_default(sizes::NAME);
+
+    // Read return_type and check_type
+    let return_type = reader.read_u32().unwrap_or(0);
+    let check_type = reader.read_u32().unwrap_or(0);
+    let success_value = reader.read_i64().unwrap_or(0);
+    let success_min = reader.read_i64().unwrap_or(0);
+    let success_max = reader.read_i64().unwrap_or(0);
+
+    let error_values_ptr = reader.read_ptr().unwrap_or(0);
+    let error_count = reader.read_u32().unwrap_or(0);
+    // Skip is_success function pointer
+    let _ = reader.read_ptr();
+
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    // Return a spec even if type_name is empty, as long as we have some data
+    // The type_name might be a string like "KAPI_TYPE_INT" that gets stored literally
+    if type_name.is_empty() && return_type == 0 && check_type == 0 && success_value == 0 {
+        // No return spec at all
+        return None;
+    }
+
+    let mut ret = ReturnSpec {
+        type_name,
+        description,
+        return_type,
+        check_type,
+        success_value: Some(success_value),
+        success_min: Some(success_min),
+        success_max: Some(success_max),
+        error_values: reader
+            .resolve_s64_array(error_values_ptr, error_count)
+            .into_iter()
+            .filter_map(|v| i32::try_from(v).ok())
+            .collect(),
+    };
+    ret.keep_used_success_fields();
+    Some(ret)
+}
+
+fn parse_error(reader: &mut DataReader) -> Option<ErrorSpec> {
+    let error_code = reader.read_i32()?;
+    let name = reader.read_string_or_default(sizes::NAME);
+    let condition = reader.read_string_or_default(sizes::DESC);
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(ErrorSpec {
+        error_code,
+        name,
+        condition,
+        description,
+    })
+}
+
+fn parse_lock(reader: &mut DataReader) -> Option<LockSpec> {
+    let lock_name = reader.read_string_or_default(sizes::NAME);
+    let lock_type = reader.read_u32()?;
+    let scope = reader.read_u32()?;
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(LockSpec {
+        lock_name,
+        lock_type,
+        scope,
+        description,
+    })
+}
+
+fn parse_constraint(reader: &mut DataReader) -> Option<ConstraintSpec> {
+    let name = reader.read_string_or_default(sizes::NAME);
+    let description = reader.read_string_or_default(sizes::DESC);
+    let expression = reader.read_string_or_default(sizes::DESC);
+
+    Some(ConstraintSpec {
+        name,
+        description,
+        expression: opt_string(expression),
+    })
+}
+
+fn parse_signal(reader: &mut DataReader) -> Option<SignalSpec> {
+    // Matches `struct kapi_signal_spec`. All string fields are pointers.
+    let signal_num = reader.read_i32()?;
+    let signal_name = reader.read_optional_string(sizes::NAME).unwrap_or_default();
+    let direction = reader.read_u32()?;
+    let action = reader.read_u32()?;
+    let target = reader.read_optional_string(sizes::DESC);
+    let condition = reader.read_optional_string(sizes::DESC);
+    let description = reader.read_optional_string(sizes::DESC);
+    let restartable = reader.read_bool()?;
+    let sa_flags_required = reader.read_u32()?;
+    let sa_flags_forbidden = reader.read_u32()?;
+    let error_on_signal = reader.read_i32()?;
+    let transform_to = reader.read_i32()?;
+    // Read the symbolic timing token (const char *) and map it to the
+    // numeric timing code used by downstream consumers.
+    let timing_str = reader.read_optional_string(sizes::NAME).unwrap_or_default();
+    let timing = match timing_str.as_str() {
+        "KAPI_SIGNAL_TIME_BEFORE" | "before" => 0u32,
+        "KAPI_SIGNAL_TIME_DURING" | "during" => 1,
+        "KAPI_SIGNAL_TIME_AFTER" | "after" => 2,
+        _ => 0,
+    };
+    let priority = reader.read_u8()?;
+    let interruptible = reader.read_bool()?;
+    let queue_behavior = reader.read_optional_string(sizes::NAME);
+    let state_required = reader.read_u32()?;
+    let state_forbidden = reader.read_u32()?;
+
+    Some(SignalSpec {
+        signal_num,
+        signal_name,
+        direction,
+        action,
+        target,
+        condition,
+        description,
+        timing,
+        priority: priority as u32,
+        restartable,
+        interruptible,
+        queue: queue_behavior,
+        sa_flags: 0, // Not a field of struct kapi_signal_spec
+        sa_flags_required,
+        sa_flags_forbidden,
+        state_required,
+        state_forbidden,
+        // `error_on_signal` of 0 means "no errno returned"; surface
+        // that as None to match the source-parser convention.
+        error_on_signal: if error_on_signal != 0 {
+            Some(error_on_signal)
+        } else {
+            None
+        },
+        transform_to: if transform_to != 0 {
+            // The compiled struct holds the numeric value; the C
+            // preprocessor already resolved any signal symbol.
+            Some(transform_to)
+        } else {
+            None
+        },
+    })
+}
+
+fn parse_signal_mask(reader: &mut DataReader) -> Option<SignalMaskSpec> {
+    let name = reader.read_string_or_default(sizes::NAME);
+
+    let mut signals = Vec::with_capacity(sizes::MAX_SIGNALS);
+    for _ in 0..sizes::MAX_SIGNALS {
+        signals.push(reader.read_i32()?);
+    }
+    let signal_count = reader.read_u32()?;
+    signals.truncate(signal_count as usize);
+
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(SignalMaskSpec {
+        name,
+        description,
+        signals,
+    })
+}
+
+fn parse_struct_field(reader: &mut DataReader) -> Option<StructFieldSpec> {
+    let name = reader.read_string_or_default(sizes::NAME);
+    let field_type = reader.read_u32()?;
+    let type_name = reader.read_string_or_default(sizes::NAME);
+    let offset = reader.read_usize()?;
+    let size = reader.read_usize()?;
+    let flags = reader.read_u32()?;
+    let constraint_type = reader.read_u32()?;
+    let min_value = reader.read_i64()?;
+    let max_value = reader.read_i64()?;
+    let valid_mask = reader.read_u64()?;
+    // enum_values is a `const char *` that StructFieldSpec has no slot for
+    reader.read_ptr()?;
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(StructFieldSpec {
+        name,
+        field_type,
+        type_name,
+        offset,
+        size,
+        flags,
+        constraint_type,
+        min_value,
+        max_value,
+        valid_mask,
+        description,
+    })
+}
+
+fn parse_struct_spec(reader: &mut DataReader) -> Option<StructSpec> {
+    let name = reader.read_string_or_default(sizes::NAME);
+    let size = reader.read_usize()?;
+    let alignment = reader.read_usize()?;
+    let field_count = reader.read_u32()?;
+
+    let mut fields = Vec::new();
+    for i in 0..sizes::MAX_PARAMS {
+        let field = parse_struct_field(reader)?;
+        reader.align_to(reader.ptr_size());
+        if i < field_count as usize {
+            fields.push(field);
+        }
+    }
+
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(StructSpec {
+        name,
+        size,
+        alignment,
+        field_count: fields.len() as u32,
+        fields,
+        description,
+    })
+}
+
+fn parse_side_effect(reader: &mut DataReader) -> Option<SideEffectSpec> {
+    let effect_type = reader.read_u32()?;
+    let target = reader.read_string_or_default(sizes::NAME);
+    let condition = reader.read_string_or_default(sizes::DESC);
+    let description = reader.read_string_or_default(sizes::DESC);
+    let reversible = reader.read_bool()?;
+
+    Some(SideEffectSpec {
+        effect_type,
+        target,
+        condition: opt_string(condition),
+        description,
+        reversible,
+    })
+}
+
+fn parse_state_transition(reader: &mut DataReader) -> Option<StateTransitionSpec> {
+    let from_state = reader.read_string_or_default(sizes::NAME);
+    let to_state = reader.read_string_or_default(sizes::NAME);
+    let condition = reader.read_string_or_default(sizes::DESC);
+    let object = reader.read_string_or_default(sizes::NAME);
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(StateTransitionSpec {
+        object,
+        from_state,
+        to_state,
+        condition: opt_string(condition),
+        description,
+    })
+}
+
+fn parse_capability(reader: &mut DataReader) -> Option<CapabilitySpec> {
+    // Struct layout matches `struct kapi_capability_spec`:
+    //   int capability; const char *cap_name; enum action;
+    //   const char *allows; const char *without_cap;
+    //   const char *check_condition; u8 priority;
+    //   int alternative[KAPI_MAX_CAPABILITIES]; u32 alternative_count;
+    let capability = reader.read_i32()?;
+    let cap_name = reader.read_string_or_default(sizes::NAME);
+    let action = reader.read_u32()?;
+    let allows = reader.read_string_or_default(sizes::DESC);
+    let without_cap = reader.read_string_or_default(sizes::DESC);
+    let check_condition = reader.read_optional_string(sizes::DESC);
+    let priority = reader.read_u8()?;
+
+    let mut alternatives = Vec::with_capacity(sizes::MAX_CAPABILITIES);
+    for _ in 0..sizes::MAX_CAPABILITIES {
+        alternatives.push(reader.read_i32()?);
+    }
+    let alternative_count = reader.read_u32()?;
+    alternatives.truncate(alternative_count as usize);
+
+    Some(CapabilitySpec {
+        capability,
+        name: cap_name,
+        action: capability_action_to_string(action),
+        allows,
+        without_cap,
+        check_condition,
+        priority: Some(priority),
+        alternatives,
+    })
+}
+
+/// Map the `enum kapi_capability_action` numeric value to its symbolic
+/// spelling, matching `include/linux/kernel_api_spec.h`.
+fn capability_action_to_string(n: u32) -> String {
+    match n {
+        0 => "KAPI_CAP_BYPASS_CHECK",
+        1 => "KAPI_CAP_INCREASE_LIMIT",
+        2 => "KAPI_CAP_OVERRIDE_RESTRICTION",
+        3 => "KAPI_CAP_GRANT_PERMISSION",
+        4 => "KAPI_CAP_MODIFY_BEHAVIOR",
+        5 => "KAPI_CAP_ACCESS_RESOURCE",
+        6 => "KAPI_CAP_PERFORM_OPERATION",
+        _ => return n.to_string(),
+    }
+    .to_string()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn state_transition_with_empty_strings_consumes_the_whole_struct() {
+        // five `const char *` slots: from_state, to_state, condition,
+        // object, description
+        let data = [0u8; 5 * 8];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+
+        let trans = parse_state_transition(&mut reader).unwrap();
+
+        assert_eq!(reader.pos, data.len());
+        assert_eq!(trans.from_state, "");
+        assert_eq!(trans.condition, None);
+        assert_eq!(trans.description, "");
+    }
+
+    // Offsets inside `struct kernel_api_spec` and its element structs as
+    // laid out by gcc on x86-64, taken from offsetof()/sizeof().
+    const SPEC_SIZE: usize = 26400;
+    const PARAM_MAGIC: usize = 36;
+    const PARAMS: usize = 48;
+    const PARAM_SIZE: usize = 120;
+    const RETURN_MAGIC: usize = 1968;
+    const RETURN_SPEC: usize = 1976;
+    const ERROR_MAGIC: usize = 2048;
+    const ERRORS: usize = 2056;
+    const ERROR_SIZE: usize = 32;
+    const LOCK_MAGIC: usize = 3080;
+    const LOCKS: usize = 3088;
+    const LOCK_SIZE: usize = 24;
+    const CONSTRAINT_MAGIC: usize = 3472;
+    const CONSTRAINTS: usize = 3480;
+    const CONSTRAINT_SIZE: usize = 24;
+    const INFO_MAGIC: usize = 4248;
+    const NOTES: usize = 4264;
+    const SIGNAL_MAGIC: usize = 4272;
+    const SIGNALS: usize = 4280;
+    const SIGNAL_SIZE: usize = 104;
+    const SIGMASK_MAGIC: usize = 7608;
+    const SIGNAL_MASKS: usize = 7616;
+    const SIGNAL_MASK_SIZE: usize = 152;
+    const STRUCT_MAGIC: usize = 12480;
+    const STRUCT_SPECS: usize = 12488;
+    const STRUCT_SPEC_SIZE: usize = 1448;
+    const STRUCT_FIELD_SIZE: usize = 88;
+    const EFFECT_MAGIC: usize = 24072;
+    const SIDE_EFFECTS: usize = 24080;
+    const SIDE_EFFECT_SIZE: usize = 40;
+    const TRANS_MAGIC: usize = 25360;
+    const STATE_TRANSITIONS: usize = 25368;
+    const STATE_TRANSITION_SIZE: usize = 40;
+    const CAP_MAGIC: usize = 25688;
+    const CAPABILITIES: usize = 25696;
+    const CAPABILITY_SIZE: usize = 88;
+
+    const BASE: u64 = 0xffff_ffff_8100_0000;
+    const CONTENT_OFFSET: usize = 0x1000;
+
+    /// A `struct kernel_api_spec` image preceded by the strings and
+    /// `s64` arrays it points at, wrapped in a minimal ELF file.
+    struct Image {
+        spec: Vec<u8>,
+        tail: Vec<u8>,
+    }
+
+    impl Image {
+        fn new() -> Self {
+            Image {
+                spec: vec![0; SPEC_SIZE],
+                tail: Vec::new(),
+            }
+        }
+
+        fn u32(&mut self, at: usize, v: u32) {
+            self.spec[at..at + 4].copy_from_slice(&v.to_le_bytes());
+        }
+
+        fn u64(&mut self, at: usize, v: u64) {
+            self.spec[at..at + 8].copy_from_slice(&v.to_le_bytes());
+        }
+
+        fn tail_vaddr(&self) -> u64 {
+            BASE + self.tail.len() as u64
+        }
+
+        fn string(&mut self, at: usize, s: &str) {
+            let vaddr = self.tail_vaddr();
+            self.tail.extend_from_slice(s.as_bytes());
+            self.tail.push(0);
+            self.u64(at, vaddr);
+        }
+
+        fn s64s(&mut self, at: usize, vals: &[i64]) {
+            while self.tail.len() % 8 != 0 {
+                self.tail.push(0);
+            }
+            let vaddr = self.tail_vaddr();
+            for v in vals {
+                self.tail.extend_from_slice(&v.to_le_bytes());
+            }
+            self.u64(at, vaddr);
+        }
+
+        fn elf(&self) -> Vec<u8> {
+            let mut content = self.tail.clone();
+            content.extend_from_slice(&self.spec);
+
+            let mut img = vec![0u8; CONTENT_OFFSET];
+            img[..4].copy_from_slice(b"\x7fELF");
+            img[4] = 2; // ELFCLASS64
+            img[5] = 1; // ELFDATA2LSB
+            img[6] = 1; // EV_CURRENT
+            img[16..18].copy_from_slice(&2u16.to_le_bytes()); // ET_EXEC
+            img[18..20].copy_from_slice(&62u16.to_le_bytes()); // EM_X86_64
+            img[20..24].copy_from_slice(&1u32.to_le_bytes());
+            img[40..48].copy_from_slice(&64u64.to_le_bytes()); // e_shoff
+            img[52..54].copy_from_slice(&64u16.to_le_bytes()); // e_ehsize
+            img[58..60].copy_from_slice(&64u16.to_le_bytes()); // e_shentsize
+            img[60..62].copy_from_slice(&2u16.to_le_bytes()); // e_shnum
+            let sh = 64 + 64; // section 1 follows the null section header at 64
+            img[sh + 4..sh + 8].copy_from_slice(&1u32.to_le_bytes()); // SHT_PROGBITS
+            img[sh + 8..sh + 16].copy_from_slice(&2u64.to_le_bytes()); // SHF_ALLOC
+            img[sh + 16..sh + 24].copy_from_slice(&BASE.to_le_bytes());
+            img[sh + 24..sh + 32].copy_from_slice(&(CONTENT_OFFSET as u64).to_le_bytes());
+            img[sh + 32..sh + 40].copy_from_slice(&(content.len() as u64).to_le_bytes());
+            img[sh + 48..sh + 56].copy_from_slice(&8u64.to_le_bytes());
+            img.extend_from_slice(&content);
+            img
+        }
+
+        fn parse(&mut self) -> Result<ApiSpec> {
+            self.string(0, "kapi_fixture");
+            while self.tail.len() % 8 != 0 {
+                self.tail.push(0);
+            }
+            let img = self.elf();
+            parse_binary_to_api_spec(&img, CONTENT_OFFSET + self.tail.len(), Endian::Little, true)
+        }
+    }
+
+    #[test]
+    fn every_array_slot_is_consumed() {
+        let mut img = Image::new();
+        img.u32(PARAM_MAGIC, magic::PARAMS);
+        img.u32(PARAM_MAGIC + 4, 16);
+        img.u32(RETURN_MAGIC, magic::RETURN);
+        img.u32(ERROR_MAGIC, magic::ERRORS);
+        img.u32(ERROR_MAGIC + 4, 32);
+        img.u32(LOCK_MAGIC, magic::LOCKS);
+        img.u32(LOCK_MAGIC + 4, 16);
+        img.u32(CONSTRAINT_MAGIC, magic::CONSTRAINTS);
+        img.u32(CONSTRAINT_MAGIC + 4, 32);
+        img.u32(SIGNAL_MAGIC, magic::SIGNALS);
+        img.u32(SIGNAL_MAGIC + 4, 32);
+        img.u32(SIGMASK_MAGIC, magic::SIGMASK);
+        img.u32(SIGMASK_MAGIC + 4, 32);
+        img.u32(STRUCT_MAGIC, magic::STRUCTS);
+        img.u32(STRUCT_MAGIC + 4, 8);
+        img.u32(EFFECT_MAGIC, magic::EFFECTS);
+        img.u32(EFFECT_MAGIC + 4, 32);
+        img.u32(TRANS_MAGIC, magic::TRANS);
+        img.u32(TRANS_MAGIC + 4, 8);
+        img.u32(CAP_MAGIC, magic::CAPS);
+        img.u32(CAP_MAGIC + 4, 8);
+
+        img.string(PARAMS + 15 * PARAM_SIZE, "last_param");
+        img.u32(ERRORS + 31 * ERROR_SIZE, -7i32 as u32);
+        img.string(ERRORS + 31 * ERROR_SIZE + 8, "ELAST");
+        img.string(LOCKS + 15 * LOCK_SIZE, "last_lock");
+        img.string(CONSTRAINTS + 31 * CONSTRAINT_SIZE, "last_constraint");
+        img.u32(SIGNALS + 31 * SIGNAL_SIZE, 31);
+        img.u32(SIGNAL_MASKS + 31 * SIGNAL_MASK_SIZE + 8, 64);
+        img.u32(SIGNAL_MASKS + 31 * SIGNAL_MASK_SIZE + 136, 1);
+        img.string(SIGNAL_MASKS + 31 * SIGNAL_MASK_SIZE, "last mask");
+        img.string(SIGNAL_MASKS + 31 * SIGNAL_MASK_SIZE + 144, "mask desc");
+
+        let last_struct = STRUCT_SPECS + 7 * STRUCT_SPEC_SIZE;
+        img.string(last_struct, "last_struct");
+        img.u32(last_struct + 24, 16);
+        img.string(last_struct + 32 + 15 * STRUCT_FIELD_SIZE, "last_field");
+        img.string(last_struct + 32 + 15 * STRUCT_FIELD_SIZE + 80, "field desc");
+        img.string(last_struct + 1440, "struct desc");
+
+        img.string(SIDE_EFFECTS + 31 * SIDE_EFFECT_SIZE + 8, "last_effect");
+        img.string(
+            STATE_TRANSITIONS + 7 * STATE_TRANSITION_SIZE + 24,
+            "last_object",
+        );
+        let last_cap = CAPABILITIES + 7 * CAPABILITY_SIZE;
+        img.u32(last_cap, 40);
+        img.u32(last_cap + 52 + 7 * 4, 99);
+        img.u32(last_cap + 84, 8);
+
+        let spec = img.parse().unwrap();
+
+        assert_eq!(spec.parameters.len(), 16);
+        assert_eq!(spec.parameters[15].name, "last_param");
+        assert_eq!(spec.errors.len(), 32);
+        assert_eq!(spec.errors[31].error_code, -7);
+        assert_eq!(spec.errors[31].name, "ELAST");
+        assert_eq!(spec.locks[15].lock_name, "last_lock");
+        assert_eq!(spec.constraints[31].name, "last_constraint");
+        assert_eq!(spec.signals[31].signal_num, 31);
+        assert_eq!(spec.signal_masks.len(), 32);
+        assert_eq!(spec.signal_masks[31].name, "last mask");
+        assert_eq!(spec.signal_masks[31].signals, [64]);
+        assert_eq!(spec.signal_masks[31].description, "mask desc");
+
+        let st = &spec.struct_specs[7];
+        assert_eq!(st.name, "last_struct");
+        assert_eq!(st.description, "struct desc");
+        assert_eq!(st.field_count, 16);
+        assert_eq!(st.fields[15].name, "last_field");
+        assert_eq!(st.fields[15].description, "field desc");
+
+        assert_eq!(spec.side_effects[31].target, "last_effect");
+        assert_eq!(spec.state_transitions[7].object, "last_object");
+        let cap = &spec.capabilities[7];
+        assert_eq!(cap.capability, 40);
+        assert_eq!(cap.alternatives, [0, 0, 0, 0, 0, 0, 0, 99]);
+    }
+
+    #[test]
+    fn notes_and_signal_masks_are_read_without_their_markers() {
+        let mut img = Image::new();
+        img.u32(SIGMASK_MAGIC + 4, 2);
+        img.string(NOTES, "only notes");
+        img.string(SIGNAL_MASKS, "first");
+        img.u32(SIGNAL_MASKS + 8, 2);
+        img.u32(SIGNAL_MASKS + 12, 15);
+        img.u32(SIGNAL_MASKS + 136, 2);
+        img.string(SIGNAL_MASKS + 144, "first desc");
+        img.string(SIGNAL_MASKS + SIGNAL_MASK_SIZE, "second");
+
+        let spec = img.parse().unwrap();
+
+        assert_eq!(spec.examples, None);
+        assert_eq!(spec.notes.as_deref(), Some("only notes"));
+        assert_eq!(spec.signal_masks.len(), 2);
+        assert_eq!(spec.signal_masks[0].name, "first");
+        assert_eq!(spec.signal_masks[0].description, "first desc");
+        assert_eq!(spec.signal_masks[0].signals, [2, 15]);
+        assert_eq!(spec.signal_masks[1].name, "second");
+        assert!(spec.signal_masks[1].signals.is_empty());
+    }
+
+    #[test]
+    fn examples_are_read_next_to_notes() {
+        let mut img = Image::new();
+        img.u32(INFO_MAGIC, magic::INFO);
+        img.string(INFO_MAGIC + 8, "an example");
+        img.string(NOTES, "a note");
+
+        let spec = img.parse().unwrap();
+
+        assert_eq!(spec.examples.as_deref(), Some("an example"));
+        assert_eq!(spec.notes.as_deref(), Some("a note"));
+    }
+
+    #[test]
+    fn enum_and_error_values_are_followed_through_their_pointers() {
+        let mut img = Image::new();
+        img.u32(PARAM_MAGIC, magic::PARAMS);
+        img.u32(PARAM_MAGIC + 4, 2);
+        img.string(PARAMS, "mode");
+        img.s64s(PARAMS + 64, &[0, 1, -7, 1 << 40]);
+        img.u32(PARAMS + 72, 4);
+        img.string(PARAMS + PARAM_SIZE, "plain");
+        img.u32(RETURN_MAGIC, magic::RETURN);
+        img.string(RETURN_SPEC, "long");
+        img.u32(RETURN_SPEC + 12, 2);
+        img.s64s(RETURN_SPEC + 40, &[-22, -2, -(1 << 40)]);
+        img.u32(RETURN_SPEC + 48, 3);
+
+        let spec = img.parse().unwrap();
+
+        assert_eq!(
+            spec.parameters[0].enum_values,
+            ["0", "1", "-7", "1099511627776"]
+        );
+        assert!(spec.parameters[1].enum_values.is_empty());
+        let ret = spec.return_spec.unwrap();
+        assert_eq!(ret.check_type, 2);
+        assert_eq!(ret.error_values, [-22, -2]);
+    }
+
+    #[test]
+    fn numbers_the_constraint_does_not_use_are_unset() {
+        let mut img = Image::new();
+        img.u32(PARAM_MAGIC, magic::PARAMS);
+        img.u32(PARAM_MAGIC + 4, 4);
+        img.string(PARAMS, "plain");
+        let ranged = PARAMS + PARAM_SIZE;
+        img.string(ranged, "ranged");
+        img.u64(ranged + 24, 16);
+        img.u64(ranged + 40, -5i64 as u64);
+        img.u64(ranged + 48, 9);
+        img.u64(ranged + 56, 0xff);
+        img.u32(ranged + 76, 1);
+        let string = PARAMS + 2 * PARAM_SIZE;
+        img.string(string, "string");
+        img.u64(string + 40, 1);
+        img.u64(string + 48, 255);
+        img.u32(string + 76, 8);
+        let unlimited = PARAMS + 3 * PARAM_SIZE;
+        img.string(unlimited, "unlimited");
+        img.u32(unlimited + 76, 8);
+        img.u32(RETURN_MAGIC, magic::RETURN);
+        img.string(RETURN_SPEC, "long");
+        img.u32(RETURN_SPEC + 12, 1);
+        img.u64(RETURN_SPEC + 16, 7);
+        img.u64(RETURN_SPEC + 32, 100);
+
+        let spec = img.parse().unwrap();
+
+        let plain = &spec.parameters[0];
+        assert_eq!((plain.min_value, plain.max_value), (None, None));
+        assert_eq!(
+            (plain.valid_mask, plain.size, plain.alignment),
+            (None, None, None)
+        );
+        let ranged = &spec.parameters[1];
+        assert_eq!((ranged.min_value, ranged.max_value), (Some(-5), Some(9)));
+        assert_eq!((ranged.valid_mask, ranged.size), (None, Some(16)));
+        let string = &spec.parameters[2];
+        assert_eq!((string.min_value, string.max_value), (Some(1), Some(255)));
+        let unlimited = &spec.parameters[3];
+        assert_eq!((unlimited.min_value, unlimited.max_value), (None, None));
+        let ret = spec.return_spec.unwrap();
+        assert_eq!(ret.success_value, None);
+        assert_eq!((ret.success_min, ret.success_max), (Some(0), Some(100)));
+    }
+
+    #[test]
+    fn enum_values_behind_a_dangling_pointer_are_dropped() {
+        let mut img = Image::new();
+        img.u32(PARAM_MAGIC, magic::PARAMS);
+        img.u32(PARAM_MAGIC + 4, 1);
+        img.string(PARAMS, "mode");
+        img.u64(PARAMS + 64, 0x1000);
+        img.u32(PARAMS + 72, 4);
+
+        let spec = img.parse().unwrap();
+
+        assert!(spec.parameters[0].enum_values.is_empty());
+    }
+
+    #[test]
+    fn unexpected_section_marker_is_rejected() {
+        let mut img = Image::new();
+        img.u32(ERROR_MAGIC, 0xdead_beef);
+
+        let err = img.parse().unwrap_err();
+
+        assert!(err.to_string().contains("0xdeadbeef"), "{err}");
+    }
+
+    #[test]
+    fn truncated_spec_is_rejected() {
+        let mut img = Image::new();
+        img.spec.truncate(SIGNAL_MASKS);
+
+        let err = img.parse().unwrap_err();
+
+        assert!(err.to_string().contains("past the end"), "{err}");
+    }
+}
diff --git a/tools/kapi/src/formatter/json.rs b/tools/kapi/src/formatter/json.rs
new file mode 100644
index 0000000000000..5f2367ad12d40
--- /dev/null
+++ b/tools/kapi/src/formatter/json.rs
@@ -0,0 +1,659 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::OutputFormatter;
+use crate::extractor::{
+    CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec, ReturnSpec, SideEffectSpec,
+    SignalMaskSpec, SignalSpec, StateTransitionSpec, StructSpec,
+};
+use serde::Serialize;
+use std::io::Write;
+
+pub struct JsonFormatter {
+    data: JsonData,
+}
+
+#[derive(Serialize)]
+struct JsonData {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    apis: Option<Vec<JsonApi>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    api_details: Option<JsonApiDetails>,
+}
+
+#[derive(Serialize)]
+struct JsonApi {
+    name: String,
+    api_type: String,
+}
+
+#[derive(Serialize)]
+struct JsonApiDetails {
+    name: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    description: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    long_description: Option<String>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    context_flags: Vec<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    examples: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    notes: Option<String>,
+    // Sysfs-specific fields
+    #[serde(skip_serializing_if = "Option::is_none")]
+    subsystem: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    sysfs_path: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    permissions: Option<String>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    capabilities: Vec<CapabilitySpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    state_transitions: Vec<StateTransitionSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    side_effects: Vec<SideEffectSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    parameters: Vec<ParamSpec>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    return_spec: Option<ReturnSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    errors: Vec<ErrorSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    locks: Vec<LockSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    struct_specs: Vec<StructSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    signals: Vec<SignalSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    signal_masks: Vec<SignalMaskSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    constraints: Vec<ConstraintSpec>,
+}
+
+impl JsonFormatter {
+    pub fn new() -> Self {
+        JsonFormatter {
+            data: JsonData {
+                apis: None,
+                api_details: None,
+            },
+        }
+    }
+}
+
+impl OutputFormatter for JsonFormatter {
+    fn begin_document(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn end_document(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        let json = serde_json::to_string_pretty(&self.data)?;
+        writeln!(w, "{json}")?;
+        Ok(())
+    }
+
+    fn begin_api_list(&mut self, _w: &mut dyn Write, _title: &str) -> std::io::Result<()> {
+        if self.data.apis.is_none() {
+            self.data.apis = Some(Vec::new());
+        }
+        Ok(())
+    }
+
+    fn api_item(&mut self, _w: &mut dyn Write, name: &str, api_type: &str) -> std::io::Result<()> {
+        if let Some(apis) = &mut self.data.apis {
+            apis.push(JsonApi {
+                name: name.to_string(),
+                api_type: api_type.to_string(),
+            });
+        }
+        Ok(())
+    }
+
+    fn end_api_list(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn total_specs(&mut self, _w: &mut dyn Write, _count: usize) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_api_details(&mut self, _w: &mut dyn Write, name: &str) -> std::io::Result<()> {
+        self.data.api_details = Some(JsonApiDetails {
+            name: name.to_string(),
+            description: None,
+            long_description: None,
+            context_flags: Vec::new(),
+            examples: None,
+            notes: None,
+            subsystem: None,
+            sysfs_path: None,
+            permissions: None,
+            capabilities: Vec::new(),
+            state_transitions: Vec::new(),
+            side_effects: Vec::new(),
+            parameters: Vec::new(),
+            return_spec: None,
+            errors: Vec::new(),
+            locks: Vec::new(),
+            struct_specs: Vec::new(),
+            signals: Vec::new(),
+            signal_masks: Vec::new(),
+            constraints: Vec::new(),
+        });
+        Ok(())
+    }
+
+    fn end_api_details(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn description(&mut self, _w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.description = Some(desc.to_string());
+        }
+        Ok(())
+    }
+
+    fn long_description(&mut self, _w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.long_description = Some(desc.to_string());
+        }
+        Ok(())
+    }
+
+    fn begin_context_flags(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn context_flag(&mut self, _w: &mut dyn Write, flag: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.context_flags.push(flag.to_string());
+        }
+        Ok(())
+    }
+
+    fn end_context_flags(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_parameters(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn end_parameters(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_errors(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn end_errors(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn examples(&mut self, _w: &mut dyn Write, examples: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.examples = Some(examples.to_string());
+        }
+        Ok(())
+    }
+
+    fn notes(&mut self, _w: &mut dyn Write, notes: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.notes = Some(notes.to_string());
+        }
+        Ok(())
+    }
+
+    fn sysfs_subsystem(&mut self, _w: &mut dyn Write, subsystem: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.subsystem = Some(subsystem.to_string());
+        }
+        Ok(())
+    }
+
+    fn sysfs_path(&mut self, _w: &mut dyn Write, path: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.sysfs_path = Some(path.to_string());
+        }
+        Ok(())
+    }
+
+    fn sysfs_permissions(&mut self, _w: &mut dyn Write, perms: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.permissions = Some(perms.to_string());
+        }
+        Ok(())
+    }
+
+    fn begin_capabilities(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn capability(&mut self, _w: &mut dyn Write, cap: &CapabilitySpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.capabilities.push(cap.clone());
+        }
+        Ok(())
+    }
+
+    fn end_capabilities(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn parameter(&mut self, _w: &mut dyn Write, param: &ParamSpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.parameters.push(param.clone());
+        }
+        Ok(())
+    }
+
+    fn return_spec(&mut self, _w: &mut dyn Write, ret: &ReturnSpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.return_spec = Some(ret.clone());
+        }
+        Ok(())
+    }
+
+    fn error(&mut self, _w: &mut dyn Write, error: &ErrorSpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.errors.push(error.clone());
+        }
+        Ok(())
+    }
+
+    fn begin_signals(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn signal(&mut self, _w: &mut dyn Write, signal: &SignalSpec) -> std::io::Result<()> {
+        if let Some(api_details) = &mut self.data.api_details {
+            api_details.signals.push(signal.clone());
+        }
+        Ok(())
+    }
+
+    fn end_signals(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_signal_masks(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn signal_mask(&mut self, _w: &mut dyn Write, mask: &SignalMaskSpec) -> std::io::Result<()> {
+        if let Some(api_details) = &mut self.data.api_details {
+            api_details.signal_masks.push(mask.clone());
+        }
+        Ok(())
+    }
+
+    fn end_signal_masks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_side_effects(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn side_effect(&mut self, _w: &mut dyn Write, effect: &SideEffectSpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.side_effects.push(effect.clone());
+        }
+        Ok(())
+    }
+
+    fn end_side_effects(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_state_transitions(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn state_transition(
+        &mut self,
+        _w: &mut dyn Write,
+        trans: &StateTransitionSpec,
+    ) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.state_transitions.push(trans.clone());
+        }
+        Ok(())
+    }
+
+    fn end_state_transitions(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_constraints(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn constraint(
+        &mut self,
+        _w: &mut dyn Write,
+        constraint: &ConstraintSpec,
+    ) -> std::io::Result<()> {
+        if let Some(api_details) = &mut self.data.api_details {
+            api_details.constraints.push(constraint.clone());
+        }
+        Ok(())
+    }
+
+    fn end_constraints(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_locks(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn lock(&mut self, _w: &mut dyn Write, lock: &LockSpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.locks.push(lock.clone());
+        }
+        Ok(())
+    }
+
+    fn end_locks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_struct_specs(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn struct_spec(&mut self, _w: &mut dyn Write, spec: &StructSpec) -> std::io::Result<()> {
+        if let Some(ref mut details) = self.data.api_details {
+            details.struct_specs.push(spec.clone());
+        }
+        Ok(())
+    }
+
+    fn end_struct_specs(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::extractor::{ErrorSpec, ParamSpec, ReturnSpec};
+
+    fn render_json(f: &mut JsonFormatter) -> String {
+        let mut buf = Vec::new();
+        f.end_document(&mut buf).unwrap();
+        String::from_utf8(buf).unwrap()
+    }
+
+    #[test]
+    fn json_output_is_valid() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.description(&mut sink, "A test syscall").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+
+        // Verify it parses as valid JSON
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+        assert_eq!(parsed["api_details"]["name"].as_str(), Some("sys_test"));
+        assert_eq!(
+            parsed["api_details"]["description"].as_str(),
+            Some("A test syscall")
+        );
+    }
+
+    #[test]
+    fn json_api_list() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_list(&mut sink, "Syscalls").unwrap();
+        f.api_item(&mut sink, "sys_open", "syscall").unwrap();
+        f.api_item(&mut sink, "sys_read", "syscall").unwrap();
+        f.end_api_list(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        let apis = parsed["apis"].as_array().unwrap();
+        assert_eq!(apis.len(), 2);
+        assert_eq!(apis[0]["name"].as_str(), Some("sys_open"));
+        assert_eq!(apis[0]["api_type"].as_str(), Some("syscall"));
+        assert_eq!(apis[1]["name"].as_str(), Some("sys_read"));
+    }
+
+    #[test]
+    fn json_special_characters_in_description() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.description(&mut sink, "Contains \"quotes\" and \\backslashes\\")
+            .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+
+        // Must be valid JSON despite special characters
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+        assert_eq!(
+            parsed["api_details"]["description"].as_str(),
+            Some("Contains \"quotes\" and \\backslashes\\")
+        );
+    }
+
+    #[test]
+    fn json_special_characters_in_name() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_list(&mut sink, "APIs").unwrap();
+        // Names with underscores (common in kernel) and unusual strings
+        f.api_item(&mut sink, "sys_new\tline", "syscall").unwrap();
+        f.end_api_list(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+
+        // Must parse correctly; serde_json handles escaping for us
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+        assert_eq!(parsed["apis"][0]["name"].as_str(), Some("sys_new\tline"));
+    }
+
+    #[test]
+    fn json_parameters_serialized() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_write").unwrap();
+        f.begin_parameters(&mut sink, 2).unwrap();
+        f.parameter(
+            &mut sink,
+            &ParamSpec {
+                index: 0,
+                name: "fd".to_string(),
+                type_name: "unsigned int".to_string(),
+                description: "file descriptor".to_string(),
+                flags: 1,
+                param_type: 2,
+                constraint_type: 0,
+                constraint: None,
+                min_value: Some(0),
+                max_value: Some(1024),
+                valid_mask: None,
+                enum_values: vec![],
+                size: None,
+                alignment: None,
+                size_param_idx: None,
+            },
+        )
+        .unwrap();
+        f.end_parameters(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        let params = parsed["api_details"]["parameters"].as_array().unwrap();
+        assert_eq!(params.len(), 1);
+        assert_eq!(params[0]["name"].as_str(), Some("fd"));
+        assert_eq!(params[0]["param_type"].as_u64(), Some(2));
+    }
+
+    #[test]
+    fn json_errors_serialized() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_read").unwrap();
+        f.begin_errors(&mut sink, 1).unwrap();
+        f.error(
+            &mut sink,
+            &ErrorSpec {
+                error_code: -9,
+                name: "EBADF".to_string(),
+                condition: "fd is not valid".to_string(),
+                description: "Bad file descriptor".to_string(),
+            },
+        )
+        .unwrap();
+        f.end_errors(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        let errors = parsed["api_details"]["errors"].as_array().unwrap();
+        assert_eq!(errors.len(), 1);
+        assert_eq!(errors[0]["name"].as_str(), Some("EBADF"));
+        assert_eq!(errors[0]["error_code"].as_i64(), Some(-9));
+    }
+
+    #[test]
+    fn json_empty_details_omits_empty_fields() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_empty").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        // description should not be present (skip_serializing_if = Option::is_none)
+        assert!(parsed["api_details"]["description"].is_null());
+        // parameters empty array should not be present (skip_serializing_if = Vec::is_empty)
+        assert!(parsed["api_details"]["parameters"].is_null());
+        // errors empty array should not be present
+        assert!(parsed["api_details"]["errors"].is_null());
+    }
+
+    #[test]
+    fn json_braces_balance() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_balanced").unwrap();
+        f.description(&mut sink, "Test braces balance").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+
+        let open_braces = json.chars().filter(|&c| c == '{').count();
+        let close_braces = json.chars().filter(|&c| c == '}').count();
+        assert_eq!(open_braces, close_braces, "Braces are unbalanced");
+
+        let open_brackets = json.chars().filter(|&c| c == '[').count();
+        let close_brackets = json.chars().filter(|&c| c == ']').count();
+        assert_eq!(open_brackets, close_brackets, "Brackets are unbalanced");
+    }
+
+    #[test]
+    fn json_return_spec_serialized() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_open").unwrap();
+        f.return_spec(
+            &mut sink,
+            &ReturnSpec {
+                type_name: "int".to_string(),
+                description: "file descriptor on success".to_string(),
+                return_type: 1,
+                check_type: 3,
+                success_value: Some(0),
+                success_min: None,
+                success_max: None,
+                error_values: vec![-1],
+            },
+        )
+        .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        let ret = &parsed["api_details"]["return_spec"];
+        assert_eq!(ret["type_name"].as_str(), Some("int"));
+        assert_eq!(ret["check_type"].as_u64(), Some(3));
+    }
+
+    #[test]
+    fn json_unicode_in_description() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_uni").unwrap();
+        f.description(&mut sink, "Supports unicode: \u{00e9}\u{00e8}\u{00ea}")
+            .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+        assert!(parsed["api_details"]["description"]
+            .as_str()
+            .unwrap()
+            .contains('\u{00e9}'));
+    }
+
+    #[test]
+    fn json_escapes_embedded_newlines() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.long_description(&mut sink, "One.\n\n- a\n- b").unwrap();
+        f.examples(&mut sink, "a();\nb();").unwrap();
+        f.notes(&mut sink, "Note one.\n\nNote two.").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        assert!(json.contains(r#""examples": "a();\nb();""#));
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+        assert_eq!(
+            parsed["api_details"]["long_description"].as_str(),
+            Some("One.\n\n- a\n- b")
+        );
+        assert_eq!(
+            parsed["api_details"]["notes"].as_str(),
+            Some("Note one.\n\nNote two.")
+        );
+    }
+}
diff --git a/tools/kapi/src/formatter/mod.rs b/tools/kapi/src/formatter/mod.rs
new file mode 100644
index 0000000000000..8d375cb310b0b
--- /dev/null
+++ b/tools/kapi/src/formatter/mod.rs
@@ -0,0 +1,220 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use crate::extractor::{
+    CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec, ReturnSpec, SideEffectSpec,
+    SignalMaskSpec, SignalSpec, StateTransitionSpec, StructSpec,
+};
+use std::io::Write;
+
+mod json;
+mod plain;
+mod rst;
+
+pub use json::JsonFormatter;
+pub use plain::PlainFormatter;
+pub use rst::RstFormatter;
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum OutputFormat {
+    Plain,
+    Json,
+    Rst,
+}
+
+impl std::str::FromStr for OutputFormat {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s.to_lowercase().as_str() {
+            "plain" => Ok(OutputFormat::Plain),
+            "json" => Ok(OutputFormat::Json),
+            "rst" => Ok(OutputFormat::Rst),
+            _ => Err(format!("Unknown output format: {}", s)),
+        }
+    }
+}
+
+/// Names of the KAPI_PARAM_* flags set in `flags`.
+fn param_flag_names(flags: u32) -> Vec<&'static str> {
+    const IN: u32 = 1 << 0;
+    const OUT: u32 = 1 << 1;
+    const OTHER: [(u32, &str); 6] = [
+        (1 << 3, "OPTIONAL"),
+        (1 << 4, "CONST"),
+        (1 << 5, "VOLATILE"),
+        (1 << 6, "USER"),
+        (1 << 7, "DMA"),
+        (1 << 8, "ALIGNED"),
+    ];
+
+    let mut names = Vec::new();
+    if flags & (IN | OUT) == IN | OUT {
+        names.push("INOUT");
+    } else if flags & IN != 0 {
+        names.push("IN");
+    } else if flags & OUT != 0 {
+        names.push("OUT");
+    }
+    names.extend(
+        OTHER
+            .iter()
+            .filter(|(bit, _)| flags & bit != 0)
+            .map(|(_, name)| *name),
+    );
+    names
+}
+
+/// Text for a parameter's bounds; either end may be unset.
+fn range_text(min: Option<i64>, max: Option<i64>) -> Option<String> {
+    match (min, max) {
+        (Some(min), Some(max)) => Some(format!("{min} to {max}")),
+        (Some(min), None) => Some(format!(">= {min}")),
+        (None, Some(max)) => Some(format!("<= {max}")),
+        (None, None) => None,
+    }
+}
+
+/// Write `text` line by line, indenting every non-blank line by `indent`.
+fn write_indented(w: &mut dyn Write, indent: &str, text: &str) -> std::io::Result<()> {
+    for line in text.lines() {
+        if line.is_empty() {
+            writeln!(w)?;
+        } else {
+            writeln!(w, "{indent}{line}")?;
+        }
+    }
+    Ok(())
+}
+
+/// Prepare text with embedded newlines for reStructuredText. Wrapped
+/// prose stays in its paragraph, but a bullet list needs a blank line
+/// before its first item and after its last one to be recognised.
+fn rst_text(text: &str) -> String {
+    let mut out: Vec<&str> = Vec::new();
+    let mut prev_bullet = false;
+    for line in text.lines() {
+        let bullet = line.starts_with("- ");
+        if !line.is_empty() && bullet != prev_bullet && out.last().is_some_and(|l| !l.is_empty()) {
+            out.push("");
+        }
+        out.push(line);
+        if !line.is_empty() {
+            prev_bullet = bullet;
+        }
+    }
+    out.join("\n")
+}
+
+pub trait OutputFormatter {
+    fn begin_document(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+    fn end_document(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_api_list(&mut self, w: &mut dyn Write, title: &str) -> std::io::Result<()>;
+    fn api_item(&mut self, w: &mut dyn Write, name: &str, api_type: &str) -> std::io::Result<()>;
+    fn end_api_list(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn total_specs(&mut self, w: &mut dyn Write, count: usize) -> std::io::Result<()>;
+
+    fn begin_api_details(&mut self, w: &mut dyn Write, name: &str) -> std::io::Result<()>;
+    fn end_api_details(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+    fn description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()>;
+    fn long_description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()>;
+
+    fn begin_context_flags(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+    fn context_flag(&mut self, w: &mut dyn Write, flag: &str) -> std::io::Result<()>;
+    fn end_context_flags(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_parameters(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn parameter(&mut self, w: &mut dyn Write, param: &ParamSpec) -> std::io::Result<()>;
+    fn end_parameters(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn return_spec(&mut self, w: &mut dyn Write, ret: &ReturnSpec) -> std::io::Result<()>;
+
+    fn begin_errors(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn error(&mut self, w: &mut dyn Write, error: &ErrorSpec) -> std::io::Result<()>;
+    fn end_errors(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn examples(&mut self, w: &mut dyn Write, examples: &str) -> std::io::Result<()>;
+    fn notes(&mut self, w: &mut dyn Write, notes: &str) -> std::io::Result<()>;
+
+    // Sysfs-specific methods
+    fn sysfs_subsystem(&mut self, w: &mut dyn Write, subsystem: &str) -> std::io::Result<()>;
+    fn sysfs_path(&mut self, w: &mut dyn Write, path: &str) -> std::io::Result<()>;
+    fn sysfs_permissions(&mut self, w: &mut dyn Write, perms: &str) -> std::io::Result<()>;
+
+    fn begin_capabilities(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+    fn capability(&mut self, w: &mut dyn Write, cap: &CapabilitySpec) -> std::io::Result<()>;
+    fn end_capabilities(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    // Signal-related methods
+    fn begin_signals(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn signal(&mut self, w: &mut dyn Write, signal: &SignalSpec) -> std::io::Result<()>;
+    fn end_signals(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_signal_masks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn signal_mask(&mut self, w: &mut dyn Write, mask: &SignalMaskSpec) -> std::io::Result<()>;
+    fn end_signal_masks(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    // Side effects and state transitions
+    fn begin_side_effects(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn side_effect(&mut self, w: &mut dyn Write, effect: &SideEffectSpec) -> std::io::Result<()>;
+    fn end_side_effects(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_state_transitions(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn state_transition(
+        &mut self,
+        w: &mut dyn Write,
+        trans: &StateTransitionSpec,
+    ) -> std::io::Result<()>;
+    fn end_state_transitions(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    // Constraints and locks
+    fn begin_constraints(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn constraint(&mut self, w: &mut dyn Write, constraint: &ConstraintSpec)
+        -> std::io::Result<()>;
+    fn end_constraints(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_locks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn lock(&mut self, w: &mut dyn Write, lock: &LockSpec) -> std::io::Result<()>;
+    fn end_locks(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_struct_specs(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn struct_spec(&mut self, w: &mut dyn Write, spec: &StructSpec) -> std::io::Result<()>;
+    fn end_struct_specs(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+}
+
+pub fn create_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
+    match format {
+        OutputFormat::Plain => Box::new(PlainFormatter::new()),
+        OutputFormat::Json => Box::new(JsonFormatter::new()),
+        OutputFormat::Rst => Box::new(RstFormatter::new()),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn param_flags_use_the_header_bit_values() {
+        assert!(param_flag_names(0).is_empty());
+        assert_eq!(param_flag_names(0x1), ["IN"]);
+        assert_eq!(param_flag_names(0x2), ["OUT"]);
+        assert_eq!(param_flag_names(0x3), ["INOUT"]);
+        assert_eq!(param_flag_names(0x42), ["OUT", "USER"]);
+        assert_eq!(param_flag_names(0x9), ["IN", "OPTIONAL"]);
+        assert_eq!(
+            param_flag_names(0x1f8),
+            ["OPTIONAL", "CONST", "VOLATILE", "USER", "DMA", "ALIGNED"]
+        );
+    }
+
+    #[test]
+    fn range_text_shows_one_sided_bounds() {
+        assert_eq!(range_text(Some(0), Some(9)).as_deref(), Some("0 to 9"));
+        assert_eq!(range_text(Some(1), None).as_deref(), Some(">= 1"));
+        assert_eq!(range_text(None, Some(255)).as_deref(), Some("<= 255"));
+        assert_eq!(range_text(None, None), None);
+    }
+}
diff --git a/tools/kapi/src/formatter/plain.rs b/tools/kapi/src/formatter/plain.rs
new file mode 100644
index 0000000000000..0aa03ab8573b6
--- /dev/null
+++ b/tools/kapi/src/formatter/plain.rs
@@ -0,0 +1,679 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::OutputFormatter;
+use crate::extractor::{
+    CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec, ReturnSpec, SideEffectSpec,
+    SignalMaskSpec, SignalSpec, StateTransitionSpec,
+};
+use std::io::Write;
+
+pub struct PlainFormatter;
+
+impl PlainFormatter {
+    pub fn new() -> Self {
+        PlainFormatter
+    }
+}
+
+impl OutputFormatter for PlainFormatter {
+    fn begin_document(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn end_document(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_api_list(&mut self, w: &mut dyn Write, title: &str) -> std::io::Result<()> {
+        writeln!(w, "\n{title}:")?;
+        writeln!(w, "{}", "-".repeat(title.len() + 1))
+    }
+
+    fn api_item(&mut self, w: &mut dyn Write, name: &str, _api_type: &str) -> std::io::Result<()> {
+        writeln!(w, "  {name}")
+    }
+
+    fn end_api_list(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn total_specs(&mut self, w: &mut dyn Write, count: usize) -> std::io::Result<()> {
+        writeln!(w, "\nTotal specifications found: {count}")
+    }
+
+    fn begin_api_details(&mut self, w: &mut dyn Write, name: &str) -> std::io::Result<()> {
+        writeln!(w, "\nDetailed information for {name}:")?;
+        writeln!(w, "{}=", "=".repeat(25 + name.len()))
+    }
+
+    fn end_api_details(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        writeln!(w, "Description: {desc}")
+    }
+
+    fn long_description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        writeln!(w, "\nDetailed Description:")?;
+        super::write_indented(w, "  ", desc)
+    }
+
+    fn begin_context_flags(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        writeln!(w, "\nExecution Context:")
+    }
+
+    fn context_flag(&mut self, w: &mut dyn Write, flag: &str) -> std::io::Result<()> {
+        writeln!(w, "  - {flag}")
+    }
+
+    fn end_context_flags(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_parameters(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nParameters ({count}):")
+    }
+
+    fn parameter(&mut self, w: &mut dyn Write, param: &ParamSpec) -> std::io::Result<()> {
+        writeln!(
+            w,
+            "  [{}] {} ({})",
+            param.index, param.name, param.type_name
+        )?;
+        if !param.description.is_empty() {
+            writeln!(w, "      {}", param.description)?;
+        }
+
+        // Display flags
+        let flags = super::param_flag_names(param.flags);
+        if !flags.is_empty() {
+            writeln!(w, "      Flags: {}", flags.join(" | "))?;
+        }
+
+        // Display constraints
+        if let Some(constraint) = &param.constraint {
+            writeln!(w, "      Constraint: {constraint}")?;
+        }
+        if let Some(range) = super::range_text(param.min_value, param.max_value) {
+            writeln!(w, "      Range: {range}")?;
+        }
+        if let Some(mask) = param.valid_mask {
+            writeln!(w, "      Valid mask: 0x{mask:x}")?;
+        }
+        Ok(())
+    }
+
+    fn end_parameters(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn return_spec(&mut self, w: &mut dyn Write, ret: &ReturnSpec) -> std::io::Result<()> {
+        writeln!(w, "\nReturn Value:")?;
+        writeln!(w, "  Type: {}", ret.type_name)?;
+        writeln!(w, "  {}", ret.description)?;
+        if let Some(val) = ret.success_value {
+            writeln!(w, "  Success value: {val}")?;
+        }
+        if let (Some(min), Some(max)) = (ret.success_min, ret.success_max) {
+            writeln!(w, "  Success range: {min} to {max}")?;
+        }
+        Ok(())
+    }
+
+    fn begin_errors(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nPossible Errors ({count}):")
+    }
+
+    fn error(&mut self, w: &mut dyn Write, error: &ErrorSpec) -> std::io::Result<()> {
+        writeln!(w, "  {} ({})", error.name, error.error_code)?;
+        if !error.condition.is_empty() {
+            writeln!(w, "      Condition: {}", error.condition)?;
+        }
+        if !error.description.is_empty() {
+            writeln!(w, "      {}", error.description)?;
+        }
+        Ok(())
+    }
+
+    fn end_errors(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn examples(&mut self, w: &mut dyn Write, examples: &str) -> std::io::Result<()> {
+        writeln!(w, "\nExamples:")?;
+        super::write_indented(w, "  ", examples)
+    }
+
+    fn notes(&mut self, w: &mut dyn Write, notes: &str) -> std::io::Result<()> {
+        writeln!(w, "\nNotes:")?;
+        super::write_indented(w, "  ", notes)
+    }
+
+    fn sysfs_subsystem(&mut self, w: &mut dyn Write, subsystem: &str) -> std::io::Result<()> {
+        writeln!(w, "Subsystem: {subsystem}")
+    }
+
+    fn sysfs_path(&mut self, w: &mut dyn Write, path: &str) -> std::io::Result<()> {
+        writeln!(w, "Sysfs Path: {path}")
+    }
+
+    fn sysfs_permissions(&mut self, w: &mut dyn Write, perms: &str) -> std::io::Result<()> {
+        writeln!(w, "Permissions: {perms}")
+    }
+
+    fn begin_capabilities(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        writeln!(w, "\nRequired Capabilities:")
+    }
+
+    fn capability(&mut self, w: &mut dyn Write, cap: &CapabilitySpec) -> std::io::Result<()> {
+        writeln!(w, "  {} ({}) - {}", cap.name, cap.capability, cap.action)?;
+        if !cap.allows.is_empty() {
+            writeln!(w, "    Allows: {}", cap.allows)?;
+        }
+        if !cap.without_cap.is_empty() {
+            writeln!(w, "    Without capability: {}", cap.without_cap)?;
+        }
+        if let Some(cond) = &cap.check_condition {
+            writeln!(w, "    Condition: {cond}")?;
+        }
+        Ok(())
+    }
+
+    fn end_capabilities(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    // Signal-related methods
+    fn begin_signals(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nSignal Specifications ({count}):")
+    }
+
+    fn signal(&mut self, w: &mut dyn Write, signal: &SignalSpec) -> std::io::Result<()> {
+        write!(w, "  {} ({})", signal.signal_name, signal.signal_num)?;
+
+        // Display direction (bitmask matching C enum kapi_signal_direction)
+        let mut dirs = Vec::new();
+        if signal.direction & 1 != 0 {
+            dirs.push("RECEIVE");
+        }
+        if signal.direction & 2 != 0 {
+            dirs.push("SEND");
+        }
+        if signal.direction & 4 != 0 {
+            dirs.push("HANDLE");
+        }
+        if signal.direction & 8 != 0 {
+            dirs.push("BLOCK");
+        }
+        if signal.direction & 16 != 0 {
+            dirs.push("IGNORE");
+        }
+        let direction = if dirs.is_empty() {
+            "UNKNOWN".to_string()
+        } else {
+            dirs.join("|")
+        };
+        write!(w, " - {direction}")?;
+
+        // Display action (matching C enum kapi_signal_action)
+        let action = match signal.action {
+            0 => "DEFAULT",
+            1 => "TERMINATE",
+            2 => "COREDUMP",
+            3 => "STOP",
+            4 => "CONTINUE",
+            5 => "CUSTOM",
+            6 => "RETURN",
+            7 => "RESTART",
+            8 => "QUEUE",
+            9 => "DISCARD",
+            10 => "TRANSFORM",
+            _ => "UNKNOWN",
+        };
+        writeln!(w, " - {action}")?;
+
+        if let Some(target) = &signal.target {
+            writeln!(w, "      Target: {target}")?;
+        }
+        if let Some(condition) = &signal.condition {
+            writeln!(w, "      Condition: {condition}")?;
+        }
+        if let Some(desc) = &signal.description {
+            writeln!(w, "      {desc}")?;
+        }
+
+        // Display timing
+        let timing = match signal.timing {
+            0 => "BEFORE",
+            1 => "DURING",
+            2 => "AFTER",
+            3 => "EXIT",
+            _ => "UNKNOWN",
+        };
+        writeln!(w, "      Timing: {timing}")?;
+        writeln!(w, "      Priority: {}", signal.priority)?;
+
+        if signal.restartable {
+            writeln!(w, "      Restartable: yes")?;
+        }
+        if signal.interruptible {
+            writeln!(w, "      Interruptible: yes")?;
+        }
+        if let Some(queue) = &signal.queue {
+            writeln!(w, "      Queue: {queue}")?;
+        }
+        if signal.sa_flags_required != 0 {
+            writeln!(
+                w,
+                "      SA flags required: {:#x}",
+                signal.sa_flags_required
+            )?;
+        }
+        if signal.sa_flags_forbidden != 0 {
+            writeln!(
+                w,
+                "      SA flags forbidden: {:#x}",
+                signal.sa_flags_forbidden
+            )?;
+        }
+        if signal.state_required != 0 {
+            writeln!(w, "      State required: {:#x}", signal.state_required)?;
+        }
+        if signal.state_forbidden != 0 {
+            writeln!(w, "      State forbidden: {:#x}", signal.state_forbidden)?;
+        }
+        if let Some(error) = signal.error_on_signal {
+            writeln!(w, "      Error on signal: {error}")?;
+        }
+        if let Some(transform) = signal.transform_to {
+            writeln!(w, "      Transform to: {transform}")?;
+        }
+        Ok(())
+    }
+
+    fn end_signals(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_signal_masks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nSignal Masks ({count}):")
+    }
+
+    fn signal_mask(&mut self, w: &mut dyn Write, mask: &SignalMaskSpec) -> std::io::Result<()> {
+        writeln!(w, "  {}", mask.name)?;
+        if !mask.description.is_empty() {
+            writeln!(w, "      {}", mask.description)?;
+        }
+        if !mask.signals.is_empty() {
+            let signals: Vec<String> = mask.signals.iter().map(i32::to_string).collect();
+            writeln!(w, "      Signals: {}", signals.join(", "))?;
+        }
+        Ok(())
+    }
+
+    fn end_signal_masks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    // Side effects and state transitions
+    fn begin_side_effects(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nSide Effects ({count}):")
+    }
+
+    fn side_effect(&mut self, w: &mut dyn Write, effect: &SideEffectSpec) -> std::io::Result<()> {
+        writeln!(w, "  {} - {}", effect.target, effect.description)?;
+        if let Some(condition) = &effect.condition {
+            writeln!(w, "      Condition: {condition}")?;
+        }
+        if effect.reversible {
+            writeln!(w, "      Reversible: yes")?;
+        }
+        Ok(())
+    }
+
+    fn end_side_effects(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_state_transitions(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nState Transitions ({count}):")
+    }
+
+    fn state_transition(
+        &mut self,
+        w: &mut dyn Write,
+        trans: &StateTransitionSpec,
+    ) -> std::io::Result<()> {
+        writeln!(
+            w,
+            "  {} : {} -> {}",
+            trans.object, trans.from_state, trans.to_state
+        )?;
+        if let Some(condition) = &trans.condition {
+            writeln!(w, "      Condition: {condition}")?;
+        }
+        if !trans.description.is_empty() {
+            writeln!(w, "      {}", trans.description)?;
+        }
+        Ok(())
+    }
+
+    fn end_state_transitions(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    // Constraints and locks
+    fn begin_constraints(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nAdditional Constraints ({count}):")
+    }
+
+    fn constraint(
+        &mut self,
+        w: &mut dyn Write,
+        constraint: &ConstraintSpec,
+    ) -> std::io::Result<()> {
+        writeln!(w, "  {}", constraint.name)?;
+        if !constraint.description.is_empty() {
+            writeln!(w, "      {}", constraint.description)?;
+        }
+        if let Some(expr) = &constraint.expression {
+            writeln!(w, "      Expression: {expr}")?;
+        }
+        Ok(())
+    }
+
+    fn end_constraints(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_locks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nLocking Requirements ({count}):")
+    }
+
+    fn lock(&mut self, w: &mut dyn Write, lock: &LockSpec) -> std::io::Result<()> {
+        write!(w, "  {}", lock.lock_name)?;
+
+        // Display lock type
+        let lock_type = match lock.lock_type {
+            0 => "NONE",
+            1 => "MUTEX",
+            2 => "SPINLOCK",
+            3 => "RWLOCK",
+            4 => "SEQLOCK",
+            5 => "RCU",
+            6 => "SEMAPHORE",
+            7 => "CUSTOM",
+            _ => "UNKNOWN",
+        };
+        writeln!(w, " ({lock_type})")?;
+
+        let scope_str = match lock.scope {
+            0 => "acquired and released",
+            1 => "acquired (not released)",
+            2 => "released (held on entry)",
+            3 => "held by caller",
+            _ => "unknown",
+        };
+        writeln!(w, "      Scope: {scope_str}")?;
+
+        if !lock.description.is_empty() {
+            writeln!(w, "      {}", lock.description)?;
+        }
+        Ok(())
+    }
+
+    fn end_locks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_struct_specs(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nStructure Specifications ({count}):")
+    }
+
+    fn struct_spec(
+        &mut self,
+        w: &mut dyn Write,
+        spec: &crate::extractor::StructSpec,
+    ) -> std::io::Result<()> {
+        writeln!(
+            w,
+            "  {} (size={}, align={}):",
+            spec.name, spec.size, spec.alignment
+        )?;
+        if !spec.description.is_empty() {
+            writeln!(w, "      {}", spec.description)?;
+        }
+
+        if !spec.fields.is_empty() {
+            writeln!(w, "      Fields ({}):", spec.field_count)?;
+            for field in &spec.fields {
+                write!(w, "        - {} ({}):", field.name, field.type_name)?;
+                if !field.description.is_empty() {
+                    write!(w, " {}", field.description)?;
+                }
+                writeln!(w)?;
+
+                // Show constraints if present
+                if field.min_value != 0 || field.max_value != 0 {
+                    writeln!(
+                        w,
+                        "          Range: [{}, {}]",
+                        field.min_value, field.max_value
+                    )?;
+                }
+                if field.valid_mask != 0 {
+                    writeln!(w, "          Mask: {:#x}", field.valid_mask)?;
+                }
+            }
+        }
+        Ok(())
+    }
+
+    fn end_struct_specs(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::extractor::{ErrorSpec, ParamSpec, ReturnSpec};
+
+    fn render_plain(f: &mut PlainFormatter, sink: &mut Vec<u8>) -> String {
+        f.end_document(sink).unwrap();
+        String::from_utf8(sink.clone()).unwrap()
+    }
+
+    #[test]
+    fn plain_api_list() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_list(&mut sink, "System Calls").unwrap();
+        f.api_item(&mut sink, "sys_open", "syscall").unwrap();
+        f.api_item(&mut sink, "sys_read", "syscall").unwrap();
+        f.end_api_list(&mut sink).unwrap();
+        f.total_specs(&mut sink, 2).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("sys_open"));
+        assert!(out.contains("sys_read"));
+        assert!(out.contains("Total specifications found: 2"));
+    }
+
+    #[test]
+    fn plain_api_details() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.description(&mut sink, "A test syscall").unwrap();
+        f.long_description(&mut sink, "Detailed description here")
+            .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("sys_test"));
+        assert!(out.contains("A test syscall"));
+        assert!(out.contains("Detailed description here"));
+    }
+
+    #[test]
+    fn plain_parameters() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_write").unwrap();
+        f.begin_parameters(&mut sink, 1).unwrap();
+        f.parameter(
+            &mut sink,
+            &ParamSpec {
+                index: 0,
+                name: "fd".to_string(),
+                type_name: "unsigned int".to_string(),
+                description: "file descriptor".to_string(),
+                flags: 1,
+                param_type: 2,
+                constraint_type: 0,
+                constraint: None,
+                min_value: None,
+                max_value: None,
+                valid_mask: None,
+                enum_values: vec![],
+                size: None,
+                alignment: None,
+                size_param_idx: None,
+            },
+        )
+        .unwrap();
+        f.end_parameters(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("fd"));
+        assert!(out.contains("unsigned int"));
+        assert!(out.contains("file descriptor"));
+    }
+
+    #[test]
+    fn plain_errors() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_errors(&mut sink, 1).unwrap();
+        f.error(
+            &mut sink,
+            &ErrorSpec {
+                error_code: -2,
+                name: "ENOENT".to_string(),
+                condition: "File not found".to_string(),
+                description: "The file does not exist".to_string(),
+            },
+        )
+        .unwrap();
+        f.end_errors(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("ENOENT"));
+        assert!(out.contains("-2"));
+        assert!(out.contains("File not found"));
+    }
+
+    #[test]
+    fn plain_return_spec() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.return_spec(
+            &mut sink,
+            &ReturnSpec {
+                type_name: "KAPI_TYPE_INT".to_string(),
+                description: "Returns 0 on success".to_string(),
+                return_type: 1,
+                check_type: 0,
+                success_value: Some(0),
+                success_min: None,
+                success_max: None,
+                error_values: vec![],
+            },
+        )
+        .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("KAPI_TYPE_INT"));
+        assert!(out.contains("Returns 0 on success"));
+    }
+
+    #[test]
+    fn plain_context_flags() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_context_flags(&mut sink).unwrap();
+        f.context_flag(&mut sink, "KAPI_CTX_PROCESS").unwrap();
+        f.context_flag(&mut sink, "KAPI_CTX_SLEEPABLE").unwrap();
+        f.end_context_flags(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("KAPI_CTX_PROCESS"));
+        assert!(out.contains("KAPI_CTX_SLEEPABLE"));
+    }
+
+    #[test]
+    fn plain_signal_mask_lists_its_signals() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_signal_masks(&mut sink, 1).unwrap();
+        f.signal_mask(
+            &mut sink,
+            &SignalMaskSpec {
+                name: "blocked".to_string(),
+                description: "Blocked while running".to_string(),
+                signals: vec![2, 15],
+            },
+        )
+        .unwrap();
+        f.end_signal_masks(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("  blocked\n      Blocked while running\n      Signals: 2, 15\n"));
+    }
+
+    #[test]
+    fn plain_multiline_blocks_are_indented() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.long_description(&mut sink, "First paragraph.\n\n- item one\n- item two")
+            .unwrap();
+        f.examples(&mut sink, "a();\nif (x) {\n  b();\n}").unwrap();
+        f.notes(&mut sink, "Note one.\n\nNote two.").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out
+            .contains("Detailed Description:\n  First paragraph.\n\n  - item one\n  - item two\n"));
+        assert!(out.contains("Examples:\n  a();\n  if (x) {\n    b();\n  }\n"));
+        assert!(out.contains("Notes:\n  Note one.\n\n  Note two.\n"));
+    }
+}
diff --git a/tools/kapi/src/formatter/rst.rs b/tools/kapi/src/formatter/rst.rs
new file mode 100644
index 0000000000000..c208d876c9777
--- /dev/null
+++ b/tools/kapi/src/formatter/rst.rs
@@ -0,0 +1,802 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::OutputFormatter;
+use crate::extractor::{
+    CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec, ReturnSpec, SideEffectSpec,
+    SignalMaskSpec, SignalSpec, StateTransitionSpec,
+};
+use std::io::Write;
+
+pub struct RstFormatter;
+
+impl RstFormatter {
+    pub fn new() -> Self {
+        RstFormatter
+    }
+
+    fn section_char(level: usize) -> char {
+        match level {
+            0 => '=',
+            1 => '-',
+            2 => '~',
+            3 => '^',
+            _ => '"',
+        }
+    }
+}
+
+impl OutputFormatter for RstFormatter {
+    fn begin_document(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn end_document(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_api_list(&mut self, w: &mut dyn Write, title: &str) -> std::io::Result<()> {
+        writeln!(w, "\n{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(0).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn api_item(&mut self, w: &mut dyn Write, name: &str, api_type: &str) -> std::io::Result<()> {
+        writeln!(w, "* **{name}** (*{api_type}*)")
+    }
+
+    fn end_api_list(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn total_specs(&mut self, w: &mut dyn Write, count: usize) -> std::io::Result<()> {
+        writeln!(w, "\n**Total specifications found:** {count}")
+    }
+
+    fn begin_api_details(&mut self, w: &mut dyn Write, name: &str) -> std::io::Result<()> {
+        writeln!(w, "\n{name}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(0).to_string().repeat(name.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn end_api_details(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        writeln!(w, "**{desc}**")?;
+        writeln!(w)
+    }
+
+    fn long_description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        writeln!(w, "{}", super::rst_text(desc))?;
+        writeln!(w)
+    }
+
+    fn begin_context_flags(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        let title = "Execution Context";
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn context_flag(&mut self, w: &mut dyn Write, flag: &str) -> std::io::Result<()> {
+        writeln!(w, "* {flag}")
+    }
+
+    fn end_context_flags(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        writeln!(w)
+    }
+
+    fn begin_parameters(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Parameters ({count})");
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn end_parameters(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_errors(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Possible Errors ({count})");
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn end_errors(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn examples(&mut self, w: &mut dyn Write, examples: &str) -> std::io::Result<()> {
+        let title = "Examples";
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)?;
+        writeln!(w, ".. code-block:: c")?;
+        writeln!(w)?;
+        super::write_indented(w, "   ", examples)?;
+        writeln!(w)
+    }
+
+    fn notes(&mut self, w: &mut dyn Write, notes: &str) -> std::io::Result<()> {
+        let title = "Notes";
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)?;
+        writeln!(w, "{}", super::rst_text(notes))?;
+        writeln!(w)
+    }
+
+    fn sysfs_subsystem(&mut self, w: &mut dyn Write, subsystem: &str) -> std::io::Result<()> {
+        writeln!(w, ":Subsystem: {subsystem}")?;
+        writeln!(w)
+    }
+
+    fn sysfs_path(&mut self, w: &mut dyn Write, path: &str) -> std::io::Result<()> {
+        writeln!(w, ":Sysfs Path: {path}")?;
+        writeln!(w)
+    }
+
+    fn sysfs_permissions(&mut self, w: &mut dyn Write, perms: &str) -> std::io::Result<()> {
+        writeln!(w, ":Permissions: {perms}")?;
+        writeln!(w)
+    }
+
+    fn begin_capabilities(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        let title = "Required Capabilities";
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn capability(&mut self, w: &mut dyn Write, cap: &CapabilitySpec) -> std::io::Result<()> {
+        writeln!(w, "**{} ({})** - {}", cap.name, cap.capability, cap.action)?;
+        writeln!(w)?;
+        if !cap.allows.is_empty() {
+            writeln!(w, "* **Allows:** {}", cap.allows)?;
+        }
+        if !cap.without_cap.is_empty() {
+            writeln!(w, "* **Without capability:** {}", cap.without_cap)?;
+        }
+        if let Some(cond) = &cap.check_condition {
+            writeln!(w, "* **Condition:** {}", cond)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_capabilities(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn parameter(&mut self, w: &mut dyn Write, param: &ParamSpec) -> std::io::Result<()> {
+        writeln!(
+            w,
+            "**[{}] {}** (*{}*)",
+            param.index, param.name, param.type_name
+        )?;
+        writeln!(w)?;
+        writeln!(w, "  {}", param.description)?;
+
+        // Display flags
+        let flags = super::param_flag_names(param.flags);
+        if !flags.is_empty() {
+            writeln!(w, "  :Flags: {}", flags.join(", "))?;
+        }
+
+        if let Some(constraint) = &param.constraint {
+            writeln!(w, "  :Constraint: {}", constraint)?;
+        }
+
+        if let Some(range) = super::range_text(param.min_value, param.max_value) {
+            writeln!(w, "  :Range: {}", range)?;
+        }
+
+        writeln!(w)
+    }
+
+    fn return_spec(&mut self, w: &mut dyn Write, ret: &ReturnSpec) -> std::io::Result<()> {
+        writeln!(w, "\nReturn Value")?;
+        writeln!(w, "{}\n", Self::section_char(1).to_string().repeat(12))?;
+        writeln!(w)?;
+        writeln!(w, ":Type: {}", ret.type_name)?;
+        writeln!(w, ":Description: {}", ret.description)?;
+        if let Some(success) = ret.success_value {
+            writeln!(w, ":Success value: {}", success)?;
+        }
+        writeln!(w)
+    }
+
+    fn error(&mut self, w: &mut dyn Write, error: &ErrorSpec) -> std::io::Result<()> {
+        writeln!(w, "**{}** ({})", error.name, error.error_code)?;
+        writeln!(w)?;
+        writeln!(w, "  :Condition: {}", error.condition)?;
+        if !error.description.is_empty() {
+            writeln!(w, "  :Description: {}", error.description)?;
+        }
+        writeln!(w)
+    }
+
+    fn begin_signals(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Signals ({count})");
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn signal(&mut self, w: &mut dyn Write, signal: &SignalSpec) -> std::io::Result<()> {
+        write!(w, "* **{}**", signal.signal_name)?;
+        if signal.signal_num != 0 {
+            write!(w, " ({})", signal.signal_num)?;
+        }
+        writeln!(w)?;
+
+        // Direction (bitmask matching C enum kapi_signal_direction)
+        let mut dirs = Vec::new();
+        if signal.direction & 1 != 0 {
+            dirs.push("receive");
+        }
+        if signal.direction & 2 != 0 {
+            dirs.push("send");
+        }
+        if signal.direction & 4 != 0 {
+            dirs.push("handle");
+        }
+        if signal.direction & 8 != 0 {
+            dirs.push("block");
+        }
+        if signal.direction & 16 != 0 {
+            dirs.push("ignore");
+        }
+        let direction = if dirs.is_empty() {
+            "unknown".to_string()
+        } else {
+            dirs.join(", ")
+        };
+        writeln!(w, "  :Direction: {}", direction)?;
+
+        // Action (matching C enum kapi_signal_action)
+        let action = match signal.action {
+            0 => "default",
+            1 => "terminate",
+            2 => "coredump",
+            3 => "stop",
+            4 => "continue",
+            5 => "custom",
+            6 => "return",
+            7 => "restart",
+            8 => "queue",
+            9 => "discard",
+            10 => "transform",
+            _ => "unknown",
+        };
+        writeln!(w, "  :Action: {}", action)?;
+
+        if let Some(target) = &signal.target {
+            writeln!(w, "  :Target: {}", target)?;
+        }
+        if let Some(cond) = &signal.condition {
+            writeln!(w, "  :Condition: {}", cond)?;
+        }
+        if let Some(desc) = &signal.description {
+            writeln!(w, "  :Description: {}", desc)?;
+        }
+        let timing = match signal.timing {
+            0 => "before",
+            1 => "during",
+            2 => "after",
+            3 => "exit",
+            _ => "",
+        };
+        if !timing.is_empty() {
+            writeln!(w, "  :Timing: {}", timing)?;
+        }
+        if signal.priority != 0 {
+            writeln!(w, "  :Priority: {}", signal.priority)?;
+        }
+        if signal.interruptible {
+            writeln!(w, "  :Interruptible: yes")?;
+        }
+        if signal.restartable {
+            writeln!(w, "  :Restartable: yes")?;
+        }
+        if let Some(queue) = &signal.queue {
+            writeln!(w, "  :Queue: {}", queue)?;
+        }
+        if signal.sa_flags_required != 0 {
+            writeln!(w, "  :SA flags required: {:#x}", signal.sa_flags_required)?;
+        }
+        if signal.sa_flags_forbidden != 0 {
+            writeln!(w, "  :SA flags forbidden: {:#x}", signal.sa_flags_forbidden)?;
+        }
+        if signal.state_required != 0 {
+            writeln!(w, "  :State required: {:#x}", signal.state_required)?;
+        }
+        if signal.state_forbidden != 0 {
+            writeln!(w, "  :State forbidden: {:#x}", signal.state_forbidden)?;
+        }
+        if let Some(error) = signal.error_on_signal {
+            writeln!(w, "  :Error on signal: {}", error)?;
+        }
+        if let Some(transform) = signal.transform_to {
+            writeln!(w, "  :Transform to: {}", transform)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_signals(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_signal_masks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Signal Masks ({count})");
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn signal_mask(&mut self, w: &mut dyn Write, mask: &SignalMaskSpec) -> std::io::Result<()> {
+        writeln!(w, "* **{}**", mask.name)?;
+        if !mask.description.is_empty() {
+            writeln!(w, "  {}", mask.description)?;
+        }
+        if !mask.signals.is_empty() {
+            let signals: Vec<String> = mask.signals.iter().map(i32::to_string).collect();
+            writeln!(w, "  Signals: {}", signals.join(", "))?;
+        }
+        writeln!(w)
+    }
+
+    fn end_signal_masks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_side_effects(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Side Effects ({count})");
+        writeln!(w, "{}\n", title)?;
+        writeln!(
+            w,
+            "{}\n",
+            Self::section_char(1).to_string().repeat(title.len())
+        )
+    }
+
+    fn side_effect(&mut self, w: &mut dyn Write, effect: &SideEffectSpec) -> std::io::Result<()> {
+        write!(w, "* **{}**", effect.target)?;
+        if effect.reversible {
+            write!(w, " *(reversible)*")?;
+        }
+        writeln!(w)?;
+        writeln!(w, "  {}", effect.description)?;
+        if let Some(cond) = &effect.condition {
+            writeln!(w, "  :Condition: {}", cond)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_side_effects(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_state_transitions(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("State Transitions ({count})");
+        writeln!(w, "{}\n", title)?;
+        writeln!(
+            w,
+            "{}\n",
+            Self::section_char(1).to_string().repeat(title.len())
+        )
+    }
+
+    fn state_transition(
+        &mut self,
+        w: &mut dyn Write,
+        trans: &StateTransitionSpec,
+    ) -> std::io::Result<()> {
+        writeln!(
+            w,
+            "* **{}**: {} → {}",
+            trans.object, trans.from_state, trans.to_state
+        )?;
+        writeln!(w, "  {}", trans.description)?;
+        if let Some(cond) = &trans.condition {
+            writeln!(w, "  :Condition: {}", cond)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_state_transitions(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_constraints(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Constraints ({count})");
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn constraint(
+        &mut self,
+        w: &mut dyn Write,
+        constraint: &ConstraintSpec,
+    ) -> std::io::Result<()> {
+        writeln!(w, "* **{}**", constraint.name)?;
+        if !constraint.description.is_empty() {
+            writeln!(w, "  {}", constraint.description)?;
+        }
+        if let Some(expr) = &constraint.expression {
+            writeln!(w, "  :Expression: ``{}``", expr)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_constraints(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_locks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Locks ({count})");
+        writeln!(w, "{}\n", title)?;
+        writeln!(
+            w,
+            "{}\n",
+            Self::section_char(1).to_string().repeat(title.len())
+        )
+    }
+
+    fn lock(&mut self, w: &mut dyn Write, lock: &LockSpec) -> std::io::Result<()> {
+        write!(w, "* **{}**", lock.lock_name)?;
+        let lock_type_str = match lock.lock_type {
+            1 => " *(mutex)*",
+            2 => " *(spinlock)*",
+            3 => " *(rwlock)*",
+            4 => " *(seqlock)*",
+            5 => " *(RCU)*",
+            6 => " *(semaphore)*",
+            7 => " *(custom)*",
+            _ => "",
+        };
+        writeln!(w, "{}", lock_type_str)?;
+        if !lock.description.is_empty() {
+            writeln!(w, "  {}", lock.description)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_locks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_struct_specs(&mut self, w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        writeln!(w)?;
+        writeln!(w, "Structure Specifications")?;
+        writeln!(w, "~~~~~~~~~~~~~~~~~~~~~~~")?;
+        writeln!(w)
+    }
+
+    fn struct_spec(
+        &mut self,
+        w: &mut dyn Write,
+        spec: &crate::extractor::StructSpec,
+    ) -> std::io::Result<()> {
+        writeln!(w, "**{}**", spec.name)?;
+        writeln!(w)?;
+
+        if !spec.description.is_empty() {
+            writeln!(w, "  {}", spec.description)?;
+            writeln!(w)?;
+        }
+
+        writeln!(w, "  :Size: {} bytes", spec.size)?;
+        writeln!(w, "  :Alignment: {} bytes", spec.alignment)?;
+        writeln!(w, "  :Fields: {}", spec.field_count)?;
+        writeln!(w)?;
+
+        if !spec.fields.is_empty() {
+            for field in &spec.fields {
+                writeln!(w, "  * **{}** ({})", field.name, field.type_name)?;
+                if !field.description.is_empty() {
+                    writeln!(w, "    {}", field.description)?;
+                }
+                if field.min_value != 0 || field.max_value != 0 {
+                    writeln!(w, "    Range: [{}, {}]", field.min_value, field.max_value)?;
+                }
+            }
+            writeln!(w)?;
+        }
+
+        Ok(())
+    }
+
+    fn end_struct_specs(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::extractor::{ErrorSpec, LockSpec, ParamSpec, ReturnSpec};
+
+    fn render_rst(f: &mut RstFormatter, sink: &mut Vec<u8>) -> String {
+        f.end_document(sink).unwrap();
+        String::from_utf8(sink.clone()).unwrap()
+    }
+
+    #[test]
+    fn rst_api_details_has_heading() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.description(&mut sink, "A test syscall").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("sys_test"));
+        assert!(out.contains("========"));
+        assert!(out.contains("**A test syscall**"));
+    }
+
+    #[test]
+    fn rst_api_list() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_list(&mut sink, "System Calls").unwrap();
+        f.api_item(&mut sink, "sys_open", "syscall").unwrap();
+        f.api_item(&mut sink, "sys_read", "syscall").unwrap();
+        f.end_api_list(&mut sink).unwrap();
+        f.total_specs(&mut sink, 2).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("sys_open"));
+        assert!(out.contains("sys_read"));
+    }
+
+    #[test]
+    fn rst_parameters() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_write").unwrap();
+        f.begin_parameters(&mut sink, 1).unwrap();
+        f.parameter(
+            &mut sink,
+            &ParamSpec {
+                index: 0,
+                name: "fd".to_string(),
+                type_name: "unsigned int".to_string(),
+                description: "file descriptor".to_string(),
+                flags: 1,
+                param_type: 2,
+                constraint_type: 0,
+                constraint: None,
+                min_value: None,
+                max_value: None,
+                valid_mask: None,
+                enum_values: vec![],
+                size: None,
+                alignment: None,
+                size_param_idx: None,
+            },
+        )
+        .unwrap();
+        f.end_parameters(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("**[0] fd**"));
+        assert!(out.contains("unsigned int"));
+        assert!(out.contains("file descriptor"));
+    }
+
+    #[test]
+    fn rst_errors() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_errors(&mut sink, 1).unwrap();
+        f.error(
+            &mut sink,
+            &ErrorSpec {
+                error_code: -2,
+                name: "ENOENT".to_string(),
+                condition: "File not found".to_string(),
+                description: "The file does not exist".to_string(),
+            },
+        )
+        .unwrap();
+        f.end_errors(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("**ENOENT**"));
+        assert!(out.contains("-2"));
+        assert!(out.contains("File not found"));
+    }
+
+    #[test]
+    fn rst_return_spec() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.return_spec(
+            &mut sink,
+            &ReturnSpec {
+                type_name: "KAPI_TYPE_INT".to_string(),
+                description: "Returns 0 on success".to_string(),
+                return_type: 1,
+                check_type: 0,
+                success_value: Some(0),
+                success_min: None,
+                success_max: None,
+                error_values: vec![],
+            },
+        )
+        .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("KAPI_TYPE_INT"));
+        assert!(out.contains("Returns 0 on success"));
+        assert!(out.contains("Return Value"));
+    }
+
+    #[test]
+    fn rst_context_flags() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_context_flags(&mut sink).unwrap();
+        f.context_flag(&mut sink, "KAPI_CTX_PROCESS").unwrap();
+        f.context_flag(&mut sink, "KAPI_CTX_SLEEPABLE").unwrap();
+        f.end_context_flags(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("KAPI_CTX_PROCESS"));
+        assert!(out.contains("KAPI_CTX_SLEEPABLE"));
+        assert!(out.contains("Execution Context"));
+    }
+
+    #[test]
+    fn rst_signal_mask_lists_its_signals() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_signal_masks(&mut sink, 1).unwrap();
+        f.signal_mask(
+            &mut sink,
+            &SignalMaskSpec {
+                name: "blocked".to_string(),
+                description: "Blocked while running".to_string(),
+                signals: vec![2, 15],
+            },
+        )
+        .unwrap();
+        f.end_signal_masks(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("* **blocked**\n  Blocked while running\n  Signals: 2, 15\n"));
+    }
+
+    #[test]
+    fn rst_examples_are_a_literal_block_with_blank_lines_kept() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.examples(&mut sink, "a();\n  b();\n\nc();").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains(".. code-block:: c\n\n   a();\n     b();\n\n   c();\n"));
+    }
+
+    #[test]
+    fn rst_bullets_get_blank_lines_around_the_list() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.long_description(&mut sink, "Intro:\n- one\n- two\n\nTail.")
+            .unwrap();
+        f.notes(&mut sink, "Para one.\n\n- a\n- b\nafter").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("Intro:\n\n- one\n- two\n\nTail.\n"));
+        assert!(out.contains("Para one.\n\n- a\n- b\n\nafter\n"));
+    }
+
+    #[test]
+    fn rst_lock_types_follow_the_kernel_enum() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        for (lock_type, label) in [
+            (1, "mutex"),
+            (2, "spinlock"),
+            (3, "rwlock"),
+            (4, "seqlock"),
+            (5, "RCU"),
+            (6, "semaphore"),
+            (7, "custom"),
+        ] {
+            f.lock(
+                &mut sink,
+                &LockSpec {
+                    lock_name: "l".to_string(),
+                    lock_type,
+                    scope: 0,
+                    description: String::new(),
+                },
+            )
+            .unwrap();
+            let out = String::from_utf8(std::mem::take(&mut sink)).unwrap();
+            assert_eq!(out, format!("* **l** *({label})*\n\n"));
+        }
+    }
+}
diff --git a/tools/kapi/src/main.rs b/tools/kapi/src/main.rs
new file mode 100644
index 0000000000000..93ed11b508e9f
--- /dev/null
+++ b/tools/kapi/src/main.rs
@@ -0,0 +1,123 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+//! kapi - Kernel API Specification Tool
+//!
+//! This tool extracts and displays kernel API specifications from multiple sources:
+//! - Kernel source code (kerneldoc blocks)
+//! - Compiled vmlinux binaries (`.kapi_specs` ELF section)
+//! - Running kernel via debugfs
+
+use anyhow::Result;
+use clap::Parser;
+use std::io::{self, Write};
+
+mod extractor;
+mod formatter;
+
+use extractor::{ApiExtractor, DebugfsExtractor, SourceExtractor, VmlinuxExtractor};
+use formatter::{create_formatter, OutputFormat};
+
+#[derive(Parser, Debug)]
+#[command(author, version, about, long_about = None)]
+struct Args {
+    /// Path to the vmlinux file
+    #[arg(long, value_name = "PATH", group = "input")]
+    vmlinux: Option<String>,
+
+    /// Path to kernel source directory or file
+    #[arg(long, value_name = "PATH", group = "input")]
+    source: Option<String>,
+
+    /// Path to debugfs (defaults to /sys/kernel/debug if not specified)
+    #[arg(long, value_name = "PATH", group = "input")]
+    debugfs: Option<String>,
+
+    /// Optional: Name of specific API to show details for
+    api_name: Option<String>,
+
+    /// Output format
+    #[arg(long, short = 'f', default_value = "plain")]
+    format: String,
+}
+
+fn main() -> Result<()> {
+    let args = Args::parse();
+
+    let output_format: OutputFormat = args
+        .format
+        .parse()
+        .map_err(|e: String| anyhow::anyhow!(e))?;
+
+    let extractor: Box<dyn ApiExtractor> = match (&args.vmlinux, &args.source, &args.debugfs) {
+        (Some(vmlinux_path), None, None) => Box::new(VmlinuxExtractor::new(vmlinux_path)?),
+        (None, Some(source_path), None) => Box::new(SourceExtractor::new(source_path)?),
+        (None, None, Some(_) | None) => {
+            // If debugfs is specified or no input is provided, use debugfs
+            Box::new(DebugfsExtractor::new(args.debugfs.clone())?)
+        }
+        _ => {
+            anyhow::bail!("Please specify only one of --vmlinux, --source, or --debugfs")
+        }
+    };
+
+    display_apis(extractor.as_ref(), args.api_name, output_format)
+}
+
+fn display_apis(
+    extractor: &dyn ApiExtractor,
+    api_name: Option<String>,
+    output_format: OutputFormat,
+) -> Result<()> {
+    let mut formatter = create_formatter(output_format);
+    let mut stdout = io::stdout();
+
+    formatter.begin_document(&mut stdout)?;
+
+    if let Some(api_name_req) = api_name {
+        // Use the extractor to display API details
+        if let Some(_spec) = extractor.extract_by_name(&api_name_req)? {
+            extractor.display_api_details(&api_name_req, &mut *formatter, &mut stdout)?;
+        } else {
+            eprintln!("API '{}' not found.", api_name_req);
+            if output_format == OutputFormat::Plain {
+                writeln!(stdout, "\nAvailable APIs:")?;
+                for spec in extractor.extract_all()? {
+                    writeln!(stdout, "  {} ({})", spec.name, spec.api_type)?;
+                }
+            }
+            std::process::exit(1);
+        }
+    } else {
+        // Display list of APIs using the extractor
+        let all_specs = extractor.extract_all()?;
+
+        // Helper to display API list for a specific type
+        let mut display_api_type = |api_type: &str, title: &str| -> Result<()> {
+            let filtered: Vec<_> = all_specs
+                .iter()
+                .filter(|s| s.api_type == api_type)
+                .collect();
+
+            if !filtered.is_empty() {
+                formatter.begin_api_list(&mut stdout, title)?;
+                for spec in filtered {
+                    formatter.api_item(&mut stdout, &spec.name, &spec.api_type)?;
+                }
+                formatter.end_api_list(&mut stdout)?;
+            }
+            Ok(())
+        };
+
+        display_api_type("syscall", "System Calls")?;
+        display_api_type("ioctl", "IOCTLs")?;
+        display_api_type("function", "Functions")?;
+        display_api_type("sysfs", "Sysfs Attributes")?;
+
+        formatter.total_specs(&mut stdout, all_specs.len())?;
+    }
+
+    formatter.end_document(&mut stdout)?;
+
+    Ok(())
+}
-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* [PATCH v5 05/11] kernel/api: add API specification for sys_open
  2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
                   ` (3 preceding siblings ...)
  2026-10-08  8:49 ` [PATCH v5 04/11] tools/kapi: add kernel API specification extraction tool Sasha Levin
@ 2026-10-08  8:49 ` Sasha Levin
  2026-10-08 12:49   ` Serge E. Hallyn
  2026-10-08  8:49 ` [PATCH v5 06/11] kernel/api: add API specification for sys_close Sasha Levin
                   ` (5 subsequent siblings)
  10 siblings, 1 reply; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

Add KAPI-annotated kerneldoc for the sys_open system call in fs/open.c.

The specification documents parameter constraints (pathname, flags
bitmask, permission mode), 24 error conditions, locking requirements,
side effects, required capabilities, and usage examples.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 fs/open.c | 324 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 324 insertions(+)

diff --git a/fs/open.c b/fs/open.c
index 6b1c14e684a93..92808ab20d52a 100644
--- a/fs/open.c
+++ b/fs/open.c
@@ -1424,6 +1424,330 @@ int do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
 }
 
 
+/**
+ * sys_open - Open or create a file
+ * @filename: Pathname of the file to open or create
+ * @flags: File access mode and behavior flags (O_RDONLY, O_WRONLY, O_RDWR, etc.)
+ * @mode: File permission bits for newly created files (only with O_CREAT/O_TMPFILE)
+ *
+ * long-desc: Opens the file named by filename, relative to the current
+ *   working directory if the path is relative. With O_CREAT, the file is
+ *   created if it does not exist. With O_TMPFILE, filename must name an
+ *   existing directory, in which an unnamed file is created. A new file gets
+ *   mode & ~umask as its permission bits.
+ *
+ *   The low two bits of flags (O_ACCMODE) select the access mode: O_RDONLY,
+ *   O_WRONLY or O_RDWR. File creation and file status flags are ORed in.
+ *
+ *   File creation flags: O_CREAT, O_EXCL, O_NOCTTY, O_TRUNC, O_DIRECTORY,
+ *   O_NOFOLLOW, O_CLOEXEC, O_TMPFILE, O_EMPTYPATH. O_EMPTYPATH permits an
+ *   empty filename, which for open() refers to the current working directory.
+ *
+ *   File status flags: O_APPEND, FASYNC, O_DIRECT, O_DSYNC, O_LARGEFILE,
+ *   O_NOATIME, O_NONBLOCK (O_NDELAY), O_PATH, O_SYNC. These become part of the
+ *   file's open file description and can be retrieved with fcntl(F_GETFL). Only
+ *   O_APPEND, O_NONBLOCK, O_DIRECT, O_NOATIME and FASYNC can be changed later
+ *   with fcntl(F_SETFL).
+ *
+ *   On success the lowest-numbered file descriptor not currently open in the
+ *   process is returned.
+ *
+ *   On 64-bit systems, O_LARGEFILE is automatically added to the flags. On 32-bit
+ *   systems, files larger than 2GB require O_LARGEFILE to be explicitly set.
+ *
+ *   open() is equivalent to openat(AT_FDCWD, filename, flags, mode).
+ *
+ * contexts: process, sleepable
+ *
+ * param: filename
+ *   type: path, input
+ *   constraint-type: user_path
+ *   cdesc: Must be a valid null-terminated path string in user memory.
+ *     Maximum path length is PATH_MAX (4096 bytes) including null terminator.
+ *     For relative paths, resolution starts from current working directory.
+ *     The path is followed (symlinks resolved) unless O_NOFOLLOW is specified.
+ *
+ * param: flags
+ *   type: int, input
+ *   constraint-type: mask(O_RDONLY | O_WRONLY | O_RDWR | O_CREAT | O_EXCL | O_NOCTTY |
+ *                         O_TRUNC | O_APPEND | O_NONBLOCK | O_NDELAY | O_DSYNC | O_SYNC |
+ *                         FASYNC | O_DIRECT | O_LARGEFILE | O_DIRECTORY | O_NOFOLLOW |
+ *                         O_NOATIME | O_CLOEXEC | O_PATH | O_TMPFILE | O_EMPTYPATH)
+ *   cdesc: Should be one of O_RDONLY (0), O_WRONLY (1), or O_RDWR (2) as the
+ *     access mode. Additional flags may be ORed. O_CREAT combined with
+ *     O_DIRECTORY or O_TMPFILE, O_TMPFILE without O_DIRECTORY, and O_TMPFILE
+ *     with read-only mode return EINVAL. With O_PATH, open() silently drops
+ *     every other flag except O_DIRECTORY, O_NOFOLLOW, O_CLOEXEC and
+ *     O_EMPTYPATH (only openat2() rejects them with EINVAL). Unknown flags are
+ *     silently ignored for backward compatibility (unlike openat2 which
+ *     rejects them).
+ *
+ * param: mode
+ *   type: uint, input
+ *   cdesc: Only meaningful when O_CREAT or O_TMPFILE is specified in
+ *     flags. Specifies the file mode bits (permissions and setuid/setgid/sticky
+ *     bits) for a newly created file. The effective mode is (mode & ~umask).
+ *     When O_CREAT/O_TMPFILE is not set, mode is ignored. Mode values exceeding
+ *     S_IALLUGO (07777) are masked off.
+ *
+ * return:
+ *   type: int
+ *   check-type: fd
+ *   success: >= 0
+ *   desc: On success, returns a new file descriptor (non-negative integer).
+ *     The returned file descriptor is the lowest-numbered descriptor not
+ *     currently open for the process. On error, returns a negative error code.
+ *
+ * error: EACCES, Permission denied
+ *   desc: The requested access to the file is not allowed, or search permission
+ *     is denied for one of the directories in the path prefix of pathname, or
+ *     the file did not exist yet and write access to the parent directory is
+ *     not allowed, or O_TRUNC is specified but write permission is denied, or
+ *     pathname is a device special file on a nodev mount, or O_CREAT on an
+ *     existing FIFO or regular file in a sticky directory is refused by
+ *     protected_fifos or protected_regular, or a security module denies the
+ *     open.
+ *
+ * error: EAGAIN, Resource temporarily unavailable
+ *   desc: O_NONBLOCK was specified and a conflicting lease is held on the file,
+ *     so the open would have to wait for the lease to break. break_lease()
+ *     returns -EWOULDBLOCK, which has the same value as EAGAIN.
+ *
+ * error: EBUSY, Device or resource busy
+ *   desc: O_EXCL was specified in flags and pathname refers to a block device
+ *     that is in use by the system (e.g., it is mounted).
+ *
+ * error: EDQUOT, Disk quota exceeded
+ *   desc: O_CREAT is specified and the file does not exist, and the user's quota
+ *     of disk blocks or inodes on the filesystem has been exhausted.
+ *
+ * error: EEXIST, File exists
+ *   desc: O_CREAT and O_EXCL were specified in flags, but pathname already exists.
+ *     This error is atomic with respect to file creation - it prevents race
+ *     conditions (TOCTOU) when creating files.
+ *
+ * error: EFAULT, Bad address
+ *   desc: pathname points outside the process's accessible address space.
+ *
+ * error: EINTR, Interrupted system call
+ *   desc: A signal arrived while the open was blocked waiting for the partner
+ *     of a FIFO open (fifo_open), waiting for a conflicting lease to break
+ *     (__break_lease), or inside a driver's open method. The kernel-internal
+ *     -ERESTARTSYS is reported as EINTR unless the handler uses SA_RESTART.
+ *
+ * error: EINVAL, Invalid argument
+ *   desc: Returned for several conditions: (1) Invalid O_* flag combinations
+ *     (O_CREAT with O_DIRECTORY, O_CREAT with O_TMPFILE, O_TMPFILE without
+ *     O_DIRECTORY, O_TMPFILE with read-only access). (2) O_DIRECT requested
+ *     but the filesystem does not support it.
+ *
+ * error: EISDIR, Is a directory
+ *   desc: pathname refers to a directory and the access requested involved
+ *     writing (O_WRONLY, O_RDWR, or O_TRUNC). Also returned when O_CREAT is
+ *     specified and pathname names an existing directory or ends in a slash.
+ *
+ * error: ELOOP, Too many symbolic links
+ *   desc: Too many symbolic links were encountered in resolving pathname, or
+ *     O_NOFOLLOW was specified but pathname refers to a symbolic link. With
+ *     O_PATH and O_NOFOLLOW the symbolic link itself is opened instead.
+ *
+ * error: EMFILE, Too many open files
+ *   desc: The per-process limit on the number of open file descriptors has been
+ *     reached. This limit is RLIMIT_NOFILE (default typically 1024, max set by
+ *     /proc/sys/fs/nr_open).
+ *
+ * error: ENAMETOOLONG, File name too long
+ *   desc: pathname was too long, exceeding PATH_MAX (4096) bytes, or a single
+ *     path component exceeded NAME_MAX (usually 255) bytes.
+ *
+ * error: ENFILE, Too many open files in system
+ *   desc: The system-wide limit on the total number of open files has been
+ *     reached (/proc/sys/fs/file-max). Processes with CAP_SYS_ADMIN can exceed
+ *     this limit.
+ *
+ * error: ENODEV, No such device
+ *   desc: The filesystem or driver open method failed with ENODEV, or the
+ *     file's inode has no file operations assigned. A device special file with
+ *     no registered device fails with ENXIO instead.
+ *
+ * error: ENOENT, No such file or directory
+ *   desc: A directory component in pathname does not exist or is a dangling
+ *     symbolic link, or O_CREAT is not set and the named file does not exist,
+ *     or pathname is an empty string and O_EMPTYPATH is not specified.
+ *
+ * error: ENOMEM, Out of memory
+ *   desc: The kernel could not allocate sufficient memory for the file structure,
+ *     path lookup structures, or the filename buffer.
+ *
+ * error: ENOSPC, No space left on device
+ *   desc: O_CREAT was specified and the file does not exist, and the directory
+ *     or filesystem containing the file has no room for a new file entry.
+ *
+ * error: ENOTDIR, Not a directory
+ *   desc: A component used as a directory in pathname is not actually a directory,
+ *     or O_DIRECTORY was specified and pathname was not a directory.
+ *
+ * error: ENXIO, No such device or address
+ *   desc: O_NONBLOCK | O_WRONLY is set and the named file is a FIFO and no
+ *     process has the FIFO open for reading. Also returned when opening a device
+ *     special file whose device does not exist (chrdev_open, blkdev_open), or
+ *     when opening a socket inode.
+ *
+ * error: EOPNOTSUPP, Operation not supported
+ *   desc: The filesystem containing pathname does not support O_TMPFILE.
+ *
+ * error: EOVERFLOW, Value too large for defined data type
+ *   desc: pathname refers to a regular file that is too large to be opened.
+ *     This occurs on 32-bit systems without O_LARGEFILE when the file size
+ *     exceeds 2GB (2^31 - 1 bytes).
+ *
+ * error: EPERM, Operation not permitted
+ *   desc: O_NOATIME flag was specified but the effective UID of the caller did
+ *     not match the owner of the file and the caller is not privileged, or the
+ *     file is append-only and O_TRUNC was specified or write mode without
+ *     O_APPEND, or the file is immutable, or a seal prevents the operation.
+ *
+ * error: EROFS, Read-only file system
+ *   desc: pathname refers to a file on a read-only filesystem and write access
+ *     was requested.
+ *
+ * error: ETXTBSY, Text file busy
+ *   desc: Write access or O_TRUNC was requested for an executable image that
+ *     is currently being executed, or O_TRUNC was requested on an active swap
+ *     file. A swap file can otherwise be opened for writing.
+ *
+ * lock: files->file_lock
+ *   type: spinlock
+ *   acquired: true
+ *   released: true
+ *   desc: Acquired when allocating a file descriptor slot. Held briefly during
+ *     fd allocation via alloc_fd() and released before the syscall returns.
+ *
+ * lock: inode->i_rwsem (parent directory)
+ *   type: semaphore
+ *   acquired: true
+ *   released: true
+ *   desc: Conditional, taken only when the final component is not resolved by
+ *     the lockless dcache lookup. lookup_open() takes it exclusively with
+ *     inode_lock() when O_CREAT is set and shared with inode_lock_shared()
+ *     otherwise. Slow-path lookup of path components takes it shared. Released
+ *     when the lookup returns. The open path has no killable variant.
+ *
+ * lock: RCU read-side
+ *   type: rcu
+ *   acquired: true
+ *   released: true
+ *   desc: Path lookup uses RCU mode initially for performance. If RCU lookup
+ *     fails (returns -ECHILD), falls back to reference-based lookup.
+ *
+ * signal: Any signal
+ *   direction: receive
+ *   action: return
+ *   condition: When blocked in an interruptible wait
+ *   desc: The syscall may be interrupted while waiting for the partner of a
+ *     FIFO open (fifo_open), for a conflicting lease to break (__break_lease),
+ *     or inside a driver's open method. The wait returns -ERESTARTSYS, which
+ *     is restarted after the handler with SA_RESTART and reported as EINTR
+ *     otherwise.
+ *   errno: -EINTR
+ *   timing: during
+ *   restartable: yes
+ *
+ * side-effect: resource_create | alloc_memory
+ *   target: file descriptor, file structure, dentry cache
+ *   desc: Allocates a new file descriptor in the process's fd table. Allocates
+ *     a struct file from the filp slab cache. May allocate dentries and inodes
+ *     during path lookup. System-wide file count (nr_files) is incremented.
+ *   reversible: yes
+ *
+ * side-effect: filesystem
+ *   target: filesystem, inode
+ *   condition: When O_CREAT is specified and file doesn't exist
+ *   desc: Creates a new file on the filesystem. Creates new inode, allocates
+ *     data blocks as needed, and creates directory entry. Updates parent
+ *     directory mtime and ctime.
+ *   reversible: no
+ *
+ * side-effect: filesystem
+ *   target: file content
+ *   condition: When O_TRUNC is specified for existing file
+ *   desc: Truncates the file to zero length, releasing data blocks. Updates
+ *     file mtime and ctime. May trigger notifications to lease holders.
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: inode timestamps
+ *   condition: When a symlink is followed, or O_TRUNC or O_CREAT takes effect
+ *   desc: Opening does not update the atime of the opened file. Reads update it
+ *     later, and O_NOATIME only affects those reads. Following a symlink in
+ *     the path may update the symlink's atime, subject to the mount atime
+ *     options. O_TRUNC and file creation update mtime and ctime.
+ *
+ * capability: CAP_DAC_OVERRIDE
+ *   type: bypass_check
+ *   allows: Bypass file read, write, and execute permission checks
+ *   without: Standard DAC (discretionary access control) checks are applied
+ *   condition: Checked when file permission would otherwise deny access
+ *
+ * capability: CAP_DAC_READ_SEARCH
+ *   type: bypass_check
+ *   allows: Bypass read permission on files and search permission on directories
+ *   without: Must have read permission on file or search permission on directory
+ *   condition: Checked during path traversal and file open
+ *
+ * capability: CAP_FOWNER
+ *   type: bypass_check
+ *   allows: Use O_NOATIME on files not owned by caller
+ *   without: O_NOATIME returns EPERM if caller is not file owner
+ *   condition: Checked when O_NOATIME is specified and caller is not owner
+ *
+ * capability: CAP_SYS_ADMIN
+ *   type: increase_limit
+ *   allows: Exceed the system-wide file limit (file-max)
+ *   without: Returns ENFILE when system limit is reached
+ *   condition: Checked in alloc_empty_file() when nr_files >= max_files
+ *
+ * constraint: RLIMIT_NOFILE (per-process fd limit)
+ *   desc: The returned file descriptor must be less than the process's
+ *     RLIMIT_NOFILE limit. Default is typically 1024, maximum is controlled
+ *     by /proc/sys/fs/nr_open (default 1048576). Exceeding returns EMFILE.
+ *   expr: fd < rlimit(RLIMIT_NOFILE)
+ *
+ * constraint: file-max (system-wide limit)
+ *   desc: System-wide limit on open files in /proc/sys/fs/file-max. Processes
+ *     without CAP_SYS_ADMIN receive ENFILE when this limit is reached. The
+ *     limit is computed based on system memory at boot time.
+ *   expr: nr_files < files_stat.max_files || capable(CAP_SYS_ADMIN)
+ *
+ * constraint: PATH_MAX
+ *   desc: Maximum length of pathname including null terminator is PATH_MAX
+ *     (4096 bytes). Individual path components must not exceed NAME_MAX (255).
+ *
+ * examples: fd = open("/etc/passwd", O_RDONLY);  // Read existing file
+ *   fd = open("/tmp/newfile", O_WRONLY | O_CREAT | O_TRUNC, 0644);  // Create/truncate
+ *   fd = open("/tmp/lockfile", O_WRONLY | O_CREAT | O_EXCL, 0600);  // Exclusive create
+ *   fd = open("/dev/null", O_RDWR);  // Open device
+ *   fd = open("/tmp", O_RDONLY | O_DIRECTORY);  // Open directory
+ *   fd = open("/tmp", O_TMPFILE | O_RDWR, 0600);  // Anonymous temp file
+ *
+ * notes: O_RDONLY is defined as 0, so (flags & O_RDONLY) always evaluates to zero.
+ *   Test access mode using (flags & O_ACCMODE) == O_RDONLY.
+ *
+ *   When O_CREAT is specified without O_EXCL, there is a race condition between
+ *   testing for file existence and creating it. Use O_CREAT | O_EXCL for atomic
+ *   exclusive file creation.
+ *
+ *   O_CLOEXEC should be used in multithreaded programs to prevent file descriptor
+ *   leaks to child processes between fork() and execve().
+ *
+ *   O_DIRECT has alignment requirements that vary by filesystem. Use statx()
+ *   with STATX_DIOALIGN (Linux 6.1+) to query requirements. Unaligned I/O may
+ *   fail with EINVAL or fall back to buffered I/O.
+ *
+ *   O_PATH opens a file descriptor that can be used only for certain operations
+ *   (fstat, dup, fcntl, close, fchdir on directories, as dirfd for *at() calls).
+ *   I/O operations will fail with EBADF.
+ */
 SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
 {
 	if (force_o_largefile())
-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* [PATCH v5 06/11] kernel/api: add API specification for sys_close
  2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
                   ` (4 preceding siblings ...)
  2026-10-08  8:49 ` [PATCH v5 05/11] kernel/api: add API specification for sys_open Sasha Levin
@ 2026-10-08  8:49 ` Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 07/11] kernel/api: add API specification for sys_read Sasha Levin
                   ` (4 subsequent siblings)
  10 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

Add KAPI-annotated kerneldoc for the sys_close system call in fs/open.c.

The specification documents the file descriptor parameter, error
conditions, locking requirements, side effects on pending I/O, and
the close-on-exec relationship.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 fs/open.c | 233 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 233 insertions(+)

diff --git a/fs/open.c b/fs/open.c
index 92808ab20d52a..9f08c438f162a 100644
--- a/fs/open.c
+++ b/fs/open.c
@@ -1861,6 +1861,239 @@ int filp_close(struct file *filp, fl_owner_t id)
 }
 EXPORT_SYMBOL(filp_close);
 
+/**
+ * sys_close - Close a file descriptor
+ * @fd: The file descriptor to close
+ *
+ * long-desc: Terminates access to an open file descriptor, releasing the file
+ *   descriptor for reuse by subsequent open(), dup(), or similar syscalls.
+ *
+ *   Traditional POSIX advisory record locks held by the process on the
+ *   associated file are released when any of its fds for that inode is
+ *   closed, not only the last one. OFD locks and flock locks are associated
+ *   with the open file description and are only released when the last
+ *   reference to that open file description is dropped.
+ *
+ *   Closing a file descriptor drops one reference to its open file
+ *   description. Other descriptors (dup(), fork(), SCM_RIGHTS messages in
+ *   flight) and operations still running on the file, such as a concurrent
+ *   read(), also hold references. Only when the last reference is dropped are
+ *   the associated resources freed. If the file was previously unlinked, the
+ *   file itself is deleted when the last reference is dropped.
+ *
+ *   Except when close() fails with EBADF, the file descriptor is released even
+ *   when close() returns an error, because it is released before the flush
+ *   that may fail. POSIX leaves the state of the descriptor unspecified after
+ *   EINTR. Retrying close() after an error may close an unrelated file
+ *   descriptor that another thread has since been given.
+ *
+ *   Errors returned from close() come only from the file's ->flush() method,
+ *   called by filp_flush(). Errors from ->release() are not reported.
+ *   Filesystems without a ->flush() method, such as ext4, xfs and btrfs, never
+ *   report write errors from close(). Network filesystems such as NFS, CIFS and
+ *   FUSE implement ->flush() and report deferred write errors (EIO, ENOSPC,
+ *   EDQUOT) at close time. A successful return does not mean the data reached
+ *   storage; call fsync() before close() for that.
+ *
+ *   On close, the following cleanup operations are performed: the ->flush()
+ *   method is called if the file has one, POSIX advisory locks are removed,
+ *   dnotify registrations are cleaned up, and the file reference is released.
+ *   If this was the last reference, additional cleanup includes: fsnotify close
+ *   notification, epoll cleanup, OFD, flock and lease removal, FASYNC cleanup,
+ *   the ->release() method, and the file structure deallocation.
+ *
+ * contexts: process, sleepable
+ *
+ * param: fd
+ *   type: fd, input
+ *   constraint-type: range(0, INT_MAX)
+ *   cdesc: Must be a valid, open file descriptor for the current process.
+ *     The value 0, 1, or 2 (stdin, stdout, stderr) may be closed like any other
+ *     fd, though this is unusual and may cause issues with libraries that assume
+ *     these descriptors are valid. The parameter is unsigned int to match kernel
+ *     file descriptor table indexing. A value that is not open, including any
+ *     value at or above the current table size, fails with EBADF.
+ *
+ * return:
+ *   type: int
+ *   check-type: exact
+ *   success: 0
+ *   desc: Returns 0 on success. On error, returns a negative error code. Except
+ *     for EBADF, the file descriptor is still closed when an error is returned
+ *     and must not be used again. The error comes from the file's ->flush()
+ *     method, not from the fd remaining open. With EBADF, nothing was released.
+ *
+ * error: EBADF, Bad file descriptor
+ *   desc: fd is at or above the file descriptor table size, has no file
+ *     assigned (including a slot reserved by a concurrent open() that has not
+ *     installed its file yet), or was already closed. This is the only error
+ *     for which no file descriptor was released.
+ *
+ * error: EINTR, Interrupted system call
+ *   desc: The flush operation was interrupted by a signal before completion.
+ *     This occurs when a driver's ->flush() method (for example wdm_flush() in
+ *     drivers/usb/class/cdc-wdm.c) performs an interruptible wait that receives
+ *     a signal. The file descriptor is still released and must not be used
+ *     again. Kernel-internal restart codes (ERESTARTSYS,
+ *     ERESTARTNOINTR, ERESTARTNOHAND, ERESTART_RESTARTBLOCK) are converted to
+ *     EINTR because restarting the syscall would be incorrect once the fd is
+ *     freed.
+ *
+ * error: EIO, I/O error
+ *   desc: The file's ->flush() method reported an I/O error, typically a
+ *     deferred write error on a network filesystem such as NFS, CIFS or FUSE,
+ *     or an error from a driver's ->flush(). Previously buffered write data
+ *     may have been lost.
+ *
+ * error: ENOSPC, No space left on device
+ *   desc: The file's ->flush() method reported that there was insufficient
+ *     space to flush buffered writes, for example on NFS when the server runs
+ *     out of space between write() and close().
+ *
+ * error: EDQUOT, Disk quota exceeded
+ *   desc: The file's ->flush() method reported that the user's disk quota was
+ *     exceeded while flushing buffered writes, for example on NFS when the
+ *     quota is exceeded between write() and close().
+ *
+ * lock: files->file_lock
+ *   type: spinlock
+ *   acquired: true
+ *   released: true
+ *   desc: Taken by file_close_fd() to look up and clear the fd slot atomically,
+ *     so two concurrent close() calls on one fd cannot both obtain the struct
+ *     file. Dropped before ->flush() and the final fput. From then on the fd
+ *     number may be handed out again. An fd reserved by a concurrent open() but
+ *     not yet installed has a NULL slot, so close() returns EBADF.
+ *
+ * lock: file->f_lock
+ *   type: spinlock
+ *   acquired: true
+ *   released: true
+ *   desc: Taken from __fput() on the last reference, by eventpoll_release_file()
+ *     when the file is registered with epoll, and by fasync_remove_entry()
+ *     (through the ->fasync() method) when FASYNC is set. Protects the epoll
+ *     and fasync links of the file.
+ *
+ * lock: ep->mtx
+ *   type: mutex
+ *   acquired: true
+ *   released: true
+ *   desc: Acquired during epoll cleanup if the file was monitored by epoll.
+ *     Used to safely remove the file from epoll interest lists.
+ *
+ * lock: flc_lock
+ *   type: spinlock
+ *   acquired: true
+ *   released: true
+ *   desc: File lock context spinlock. Taken by locks_remove_posix() from
+ *     filp_flush() on every close when the file has POSIX locks, and by
+ *     locks_remove_file() from __fput() on the last reference to remove OFD,
+ *     flock, and lease locks.
+ *
+ * signal: pending_signals
+ *   direction: receive
+ *   action: return
+ *   condition: When close-time flush performs interruptible wait
+ *   desc: If the close-time ->flush() method (for example wdm_flush() in
+ *     cdc-wdm) performs an interruptible wait and a signal is pending, the wait
+ *     is interrupted. Any kernel restart codes are converted to EINTR since
+ *     close cannot be restarted after the fd is freed.
+ *   errno: -EINTR
+ *   timing: during
+ *   restartable: no
+ *
+ * side-effect: resource_destroy | irreversible
+ *   target: File descriptor table entry
+ *   desc: The file descriptor is removed from the process's file descriptor
+ *     table, making the fd number available for reuse by subsequent open(),
+ *     dup(), or similar calls. This happens before the flush that may fail, so
+ *     an error return does not undo it.
+ *   condition: Always (when fd is valid)
+ *   reversible: no
+ *
+ * side-effect: lock_release
+ *   target: POSIX advisory locks, OFD locks, flock locks
+ *   desc: POSIX locks held by this process on the inode are removed on every
+ *     close via locks_remove_posix() in filp_flush(), except for O_PATH files.
+ *     OFD and flock locks are removed via locks_remove_file() in __fput() only
+ *     when this is the last reference to the open file description.
+ *   condition: Any close of a non-O_PATH file for POSIX locks, last reference
+ *     for OFD and flock locks
+ *   reversible: no
+ *
+ * side-effect: resource_destroy
+ *   target: File leases
+ *   desc: Any file leases held on the file are removed during locks_remove_file()
+ *     when this is the last reference to the open file description.
+ *   condition: File had leases and this is the last reference
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: dnotify registrations
+ *   desc: Directory notification (dnotify) registrations associated with this
+ *     file are cleaned up via dnotify_flush(). This only applies to directories.
+ *   condition: File is a directory with dnotify registrations
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: epoll interest lists
+ *   desc: If the file was being monitored by epoll instances, it is removed
+ *     from those interest lists via eventpoll_release(), which runs from
+ *     __fput() only on the last reference. While other references remain, the
+ *     epoll registrations stay active.
+ *   condition: File was added to epoll instances and this is the last reference
+ *   reversible: no
+ *
+ * side-effect: filesystem
+ *   target: Buffered data
+ *   desc: The file's ->flush() method runs before the file reference is
+ *     dropped (for example on NFS, CIFS and FUSE) and may return errors such as
+ *     EIO, ENOSPC or EDQUOT.
+ *   condition: The filesystem or driver provides a ->flush() method
+ *   reversible: no
+ *
+ * side-effect: free_memory
+ *   target: struct file and related structures
+ *   desc: When this is the last reference to the file, the file structure is
+ *     freed and the dentry and mount references are released.
+ *   condition: This is the last reference to the file
+ *   reversible: no
+ *
+ * side-effect: filesystem
+ *   target: Unlinked file deletion
+ *   desc: If the file was previously unlinked (deleted) but kept open, closing
+ *     the last reference causes the actual file data to be removed from the
+ *     filesystem and the inode to be freed.
+ *   condition: File was unlinked and this is the last reference
+ *   reversible: no
+ *
+ * state-trans: file_descriptor
+ *   from: open
+ *   to: closed/free
+ *   condition: Valid fd passed to close
+ *   desc: The file descriptor transitions from open (usable) to closed (invalid).
+ *     The fd number becomes available for reuse.
+ *
+ * state-trans: file_reference_count
+ *   from: n
+ *   to: n-1 (or freed if n was 1)
+ *   condition: Always on successful fd lookup
+ *   desc: The file's reference count is decremented. If this was the last
+ *     reference, the file is fully cleaned up and freed.
+ *
+ * examples: close(fd);  // Ignoring the result loses ->flush() errors
+ *   if (close(fd) == -1) perror("close");  // Log errors for debugging
+ *   fsync(fd); close(fd);  // Ensure data persistence before closing
+ *
+ * notes: close() drops its reference with fput_close_sync(), so when it is the last
+ *   reference __fput() runs synchronously in the calling task before close()
+ *   returns, instead of being deferred to task work.
+ *
+ *   Calling close() on a file descriptor while another thread is using it
+ *   (e.g., in a blocking read() or write()) does not interrupt the blocked
+ *   operation. The blocked operation continues on the underlying file and
+ *   may complete even after close() returns.
+ */
 /*
  * Careful here! We test whether the file pointer is NULL before
  * releasing the fd. This ensures that one clone task can't release
-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* [PATCH v5 07/11] kernel/api: add API specification for sys_read
  2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
                   ` (5 preceding siblings ...)
  2026-10-08  8:49 ` [PATCH v5 06/11] kernel/api: add API specification for sys_close Sasha Levin
@ 2026-10-08  8:49 ` Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 08/11] kernel/api: add API specification for sys_write Sasha Levin
                   ` (3 subsequent siblings)
  10 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

Add KAPI-annotated kerneldoc for the sys_read system call in
fs/read_write.c.

The specification documents parameter constraints (fd, user buffer,
count), error conditions, locking requirements, signal handling
behavior, and short read semantics.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 fs/read_write.c | 297 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 297 insertions(+)

diff --git a/fs/read_write.c b/fs/read_write.c
index e8c14e2760b20..b89c688a41992 100644
--- a/fs/read_write.c
+++ b/fs/read_write.c
@@ -720,6 +720,303 @@ ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
 	return ret;
 }
 
+/**
+ * sys_read - Read data from a file descriptor
+ * @fd: File descriptor to read from
+ * @buf: User-space buffer to read data into
+ * @count: Maximum number of bytes to read
+ *
+ * long-desc: Reads at most count bytes from fd into the user buffer buf. For
+ *   seekable files (regular files, block devices), the read begins at the
+ *   current file offset, and the file offset is advanced by the number of
+ *   bytes read. For stream files (FMODE_STREAM, such as pipes, FIFOs, and
+ *   sockets), the file offset is not used. Other files, including many
+ *   character devices, keep a file offset although a driver may ignore it.
+ *
+ *   If count is zero, the usual checks still run and the file's read method is
+ *   still called. Common implementations (regular files, pipes, sockets)
+ *   return zero without transferring data.
+ *
+ *   On success the number of bytes read is returned; zero means end of file
+ *   for regular files. A read can return fewer bytes than requested, for
+ *   example near end of file, on a pipe, socket or terminal with less data
+ *   pending, or when a signal arrives after some data was copied.
+ *
+ *   On Linux, read() transfers at most MAX_RW_COUNT (INT_MAX & PAGE_MASK,
+ *   0x7ffff000 with 4KB pages, just under 2GB) bytes per call, regardless of
+ *   whether the filesystem would allow more. This is to avoid issues with
+ *   signed arithmetic overflow on 32-bit systems.
+ *
+ *   POSIX allows reads that are interrupted after reading some data to either
+ *   return -1 (with errno set to EINTR) or return the number of bytes already
+ *   read. Linux follows the latter behavior: if data has been read before a
+ *   signal arrives, the call returns the bytes read rather than failing.
+ *
+ * contexts: process, sleepable
+ *
+ * param: fd
+ *   type: fd, input
+ *   constraint-type: range(0, INT_MAX)
+ *   cdesc: Must be a valid, open file descriptor with read permission.
+ *     The file must have been opened with O_RDONLY or O_RDWR. Special values
+ *     like AT_FDCWD are not valid. File descriptors for directories return
+ *     EISDIR. Standard file descriptors 0 (stdin), 1 (stdout), 2 (stderr) are
+ *     valid if open and readable.
+ *
+ * param: buf
+ *   type: user_ptr, output
+ *   constraint-type: buffer(2)
+ *   cdesc: Must point to a writable user-space region of at least count bytes.
+ *     The range is checked via access_ok() and a range outside user space
+ *     fails with EFAULT. NULL is not rejected by that check. With a count of 0
+ *     the call returns 0 in common cases. With a count above 0 the copy fails
+ *     with EFAULT, or the call returns a short count if some data was copied
+ *     first. The buffer may be partially written if an error occurs mid-read.
+ *     O_DIRECT reads may require block-size alignment (see STATX_DIOALIGN).
+ *
+ * param: count
+ *   type: uint, input
+ *   cdesc: Maximum number of bytes to read. The range buf to buf + count must
+ *     lie within user space or access_ok() fails with EFAULT, which includes
+ *     counts that do not fit in ssize_t on 64-bit kernels. A count of 0 passes
+ *     the same checks and the file's read method is still called, typically
+ *     returning 0 without transferring data. After access_ok() and
+ *     rw_verify_area() succeed, counts above MAX_RW_COUNT (INT_MAX &
+ *     PAGE_MASK, 0x7ffff000 with 4KB pages) are clamped to MAX_RW_COUNT.
+ *
+ * return:
+ *   type: int
+ *   check-type: range
+ *   success: >= 0
+ *   desc: On success, returns the number of bytes read (non-negative). Zero
+ *     indicates end-of-file (EOF) for regular files, or no data available
+ *     from a device that does not block. The return value may be less than
+ *     count if fewer bytes were available (short read). Partial reads are
+ *     not errors. On error, returns a negative error code.
+ *
+ * error: EBADF, Bad file descriptor
+ *   desc: fd is not a valid file descriptor, or fd was not opened for reading.
+ *     This includes file descriptors opened with O_WRONLY, O_PATH, or file
+ *     descriptors that have been closed. Also returned if the file structure
+ *     does not have FMODE_READ set.
+ *
+ * error: EFAULT, Bad address
+ *   desc: buf points outside the accessible address space. The buffer address
+ *     failed access_ok() validation. Can also occur if a fault happens during
+ *     copy_to_user() when transferring data to user space after the read
+ *     completes in kernel space.
+ *
+ * error: EINVAL, Invalid argument
+ *   desc: Returned in several cases: (1) The file has no read or read_iter
+ *     method (FMODE_CAN_READ is not set). (2) The file was opened with
+ *     O_DIRECT and the buffer alignment, offset, or count does not meet the
+ *     filesystem's alignment requirements. (3) For timerfd file descriptors,
+ *     the buffer is smaller than 8 bytes. (4) rw_verify_area() rejects a
+ *     negative file position, or a position plus count that overflows, on a
+ *     file without FOP_UNSIGNED_OFFSET. (5) The count, cast to ssize_t, is
+ *     negative (32-bit kernels only).
+ *
+ * error: EISDIR, Is a directory
+ *   desc: fd refers to a directory. Directories cannot be read using read();
+ *     use getdents64() instead. This error is returned by the generic_read_dir()
+ *     handler installed for directory file operations.
+ *
+ * error: EAGAIN, Resource temporarily unavailable
+ *   desc: fd refers to a file (pipe, socket, device) that is marked non-blocking
+ *     (O_NONBLOCK) and the read would block. Equivalent to EWOULDBLOCK. The
+ *     application should retry the read later or use select/poll/epoll.
+ *
+ * error: EINTR, Interrupted system call
+ *   desc: The call was interrupted by a signal before any data was read. This
+ *     only occurs if no data has been transferred; if some data was read before
+ *     the signal, the call returns the number of bytes read. The caller should
+ *     typically restart the read.
+ *
+ * error: EIO, Input/output error
+ *   desc: A low-level I/O error occurred. For regular files, this typically
+ *     indicates a hardware error on the storage device, a filesystem error,
+ *     or a network filesystem timeout. For terminals, it is returned when a
+ *     background process group reads its controlling terminal and SIGTTIN is
+ *     ignored or blocked, or the process group is orphaned.
+ *
+ * error: EOVERFLOW, Value too large for defined data type
+ *   desc: Returned by rw_verify_area() only for files with FOP_UNSIGNED_OFFSET
+ *     (for example /proc/pid/mem) when the file position is negative, meaning
+ *     above LLONG_MAX, and count is at least -pos so that the read would wrap
+ *     past the end of the 64-bit offset space. Files without
+ *     FOP_UNSIGNED_OFFSET get EINVAL for a negative position or a position
+ *     plus count that overflows.
+ *
+ * error: ENOBUFS, No buffer space available
+ *   desc: Returned when reading from pipe-based watch queues (CONFIG_WATCH_QUEUE)
+ *     when the buffer is too small to hold a complete notification, or when
+ *     reading packets from pipes with PIPE_BUF_FLAG_WHOLE set.
+ *
+ * error: ERESTARTSYS, Restart system call (internal)
+ *   desc: Internal error code indicating the syscall should be restarted. This
+ *     is typically translated to EINTR if SA_RESTART is not set on the signal
+ *     handler, or the syscall is transparently restarted if SA_RESTART is set.
+ *     User space should not see this error code directly.
+ *
+ * error: EACCES, Permission denied
+ *   desc: The security subsystem (LSM such as SELinux or AppArmor) denied
+ *     the read operation via security_file_permission(). This can occur even
+ *     if the file was successfully opened, as LSM policies may enforce checks
+ *     on each operation.
+ *
+ * error: EPERM, Operation not permitted
+ *   desc: Returned by fanotify permission events (CONFIG_FANOTIFY_ACCESS_PERMISSIONS)
+ *     when a user-space fanotify listener denies the read operation via
+ *     fsnotify_file_area_perm().
+ *
+ * error: ENODATA, No data available
+ *   desc: Returned when a filesystem built on netfs (for example AFS or Ceph)
+ *     ends a read subrequest short without making progress.
+ *
+ * error: EOPNOTSUPP, Operation not supported
+ *   desc: Returned by the read method of the file, not by the generic VFS
+ *     path. For example, a socket whose protocol has no receive method
+ *     (sock_no_recvmsg) fails with EOPNOTSUPP. A file with no read method at
+ *     all fails with EINVAL instead.
+ *
+ * lock: file->f_pos_lock
+ *   type: mutex
+ *   acquired: true
+ *   released: true
+ *   desc: For regular files that require atomic position updates (FMODE_ATOMIC_POS),
+ *     the f_pos_lock mutex is acquired by fdget_pos() at syscall entry and released
+ *     by fdput_pos() at syscall exit. This serializes concurrent reads that share
+ *     the same file description. Not acquired for files opened with FMODE_STREAM
+ *     (pipes, sockets) or when the file is not shared.
+ *
+ * lock: Filesystem-specific locks
+ *   type: custom
+ *   acquired: true
+ *   released: true
+ *   desc: The filesystem's read_iter or read method may acquire additional locks.
+ *     For regular files, this typically includes the inode's i_rwsem for certain
+ *     operations. For pipes, the pipe->mutex is acquired. For sockets, socket
+ *     lock is acquired. These are internal to the file operation and released
+ *     before return.
+ *
+ * lock: RCU read-side
+ *   type: rcu
+ *   acquired: true
+ *   released: true
+ *   desc: Taken only when the files_struct is shared with other threads, in
+ *     which case the fd lookup in fdget() uses RCU and takes a file reference.
+ *     A private table needs no RCU. The RCU read lock is acquired and released
+ *     internally by the fd lookup path, not held across the entire syscall.
+ *     fdput() releases the file reference count, not the RCU lock.
+ *
+ * signal: Any signal
+ *   direction: receive
+ *   action: return
+ *   condition: When blocked waiting for data on interruptible operations
+ *   desc: The syscall may be interrupted by signals while waiting for data to
+ *     become available (pipes, sockets, terminals). If interrupted before any
+ *     data is read, returns -EINTR or -ERESTARTSYS. If data has already been
+ *     read, returns the number of bytes read.
+ *   errno: -EINTR
+ *   timing: during
+ *   restartable: yes
+ *
+ * side-effect: file_position
+ *   target: file->f_pos
+ *   condition: For seekable files when read succeeds (returns > 0)
+ *   desc: The file offset (f_pos) is advanced by the number of bytes read.
+ *     For stream files (FMODE_STREAM such as pipes and sockets), the offset
+ *     is not used or modified. The offset update is protected by f_pos_lock
+ *     when the file is shared between threads/processes.
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: inode access time (atime)
+ *   condition: When the read_iter implementation calls file_accessed() and
+ *     O_NOATIME is not set
+ *   desc: The filesystem's read_iter implementation updates atime via
+ *     file_accessed() and touch_atime(). Buffered reads (filemap_read) do this
+ *     after the data loop, including at EOF and after errors, but not for a
+ *     count of 0. O_DIRECT reads (generic_file_read_iter) do it before the
+ *     I/O. The update may be suppressed by mount options (noatime, relatime),
+ *     the O_NOATIME flag, or if the filesystem does not support atime.
+ *     Relatime updates atime only if it is older than mtime or ctime, or more
+ *     than a day old.
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: task I/O accounting
+ *   condition: When CONFIG_TASK_XACCT is enabled and the read method is invoked
+ *   desc: Updates the current task's I/O accounting statistics. The rchar field
+ *     (read characters) is incremented by bytes read via add_rchar() only on
+ *     successful reads (ret > 0). The syscr field (syscall read count) is
+ *     incremented via inc_syscr() once the read method has been invoked, even
+ *     if it fails, but not when the FMODE_READ, FMODE_CAN_READ, access_ok() or
+ *     rw_verify_area() checks fail. These statistics are visible in
+ *     /proc/[pid]/io.
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: fsnotify events
+ *   condition: When read returns > 0
+ *   desc: Generates an FS_ACCESS fsnotify event via fsnotify_access() allowing
+ *     inotify, fanotify, and dnotify watchers to be notified of the read. This
+ *     occurs after data transfer completes successfully.
+ *   reversible: no
+ *
+ * constraint: MAX_RW_COUNT
+ *   desc: After the access_ok() and rw_verify_area() checks pass, the count
+ *     parameter is silently clamped to MAX_RW_COUNT (INT_MAX & PAGE_MASK, just
+ *     under 2GB) to prevent integer overflow in internal calculations. This is
+ *     transparent to the caller. The syscall succeeds but reads at most
+ *     MAX_RW_COUNT bytes.
+ *   expr: actual_count = min(count, MAX_RW_COUNT)
+ *
+ * constraint: File must be open for reading
+ *   desc: The file descriptor must have been opened with O_RDONLY or O_RDWR.
+ *     Files opened with O_WRONLY or O_PATH lack FMODE_READ and return EBADF.
+ *     Files that lack FMODE_CAN_READ because they have no read or read_iter
+ *     method return EINVAL.
+ *   expr: (file->f_mode & FMODE_READ) && (file->f_mode & FMODE_CAN_READ)
+ *
+ * examples: n = read(fd, buf, sizeof(buf));  // Basic read
+ *   n = read(STDIN_FILENO, buf, 1024);  // Read from stdin
+ *   while ((n = read(fd, buf, 4096)) > 0) { process(buf, n); }  // Read loop
+ *   if (read(fd, buf, count) == 0) { handle_eof(); }  // Check for EOF
+ *
+ * notes: The behavior of read() varies significantly depending on the type of
+ *   file descriptor:
+ *
+ *   - Regular files: Reads from current position, advances position, returns 0
+ *     at EOF. Short reads are rare but possible near EOF or on signal.
+ *
+ *   - Pipes and FIFOs: Blocking by default. Returns available data (up to count)
+ *     or blocks until data is available. Returns 0 when all writers have closed.
+ *     O_NONBLOCK returns EAGAIN when empty instead of blocking.
+ *
+ *   - Sockets: Similar to pipes. Specific behavior depends on socket type and
+ *     protocol. MSG_* flags can be specified via recv() for more control.
+ *
+ *   - Terminals: Line-buffered in canonical mode; read returns when newline is
+ *     entered or buffer is full. Raw mode returns immediately when data available.
+ *     Special handling for signals (SIGINT on Ctrl+C, etc.).
+ *
+ *   - Device special files: Behavior is device-specific. Some devices support
+ *     seeking, others do not. Read size may be constrained by device.
+ *
+ *   Race condition: Concurrent reads from the same file description (not just
+ *   file descriptor) can race on the file position. Linux 3.14+ provides atomic
+ *   position updates for regular files via f_pos_lock, but applications should
+ *   use pread() for concurrent positioned reads.
+ *
+ *   O_DIRECT reads bypass the page cache and typically require aligned buffers
+ *   and positions. Alignment requirements are filesystem-specific; use statx()
+ *   with STATX_DIOALIGN (Linux 6.1+) to query. Unaligned O_DIRECT reads fail
+ *   with EINVAL on most filesystems.
+ *
+ *   For splice(2)-like zero-copy reads, consider using splice(), sendfile(),
+ *   or copy_file_range() instead of read() + write().
+ */
 SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
 {
 	return ksys_read(fd, buf, count);
-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* [PATCH v5 08/11] kernel/api: add API specification for sys_write
  2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
                   ` (6 preceding siblings ...)
  2026-10-08  8:49 ` [PATCH v5 07/11] kernel/api: add API specification for sys_read Sasha Levin
@ 2026-10-08  8:49 ` Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 09/11] kernel/api: add runtime verification selftest Sasha Levin
                   ` (2 subsequent siblings)
  10 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

Add KAPI-annotated kerneldoc for the sys_write system call in
fs/read_write.c.

The specification documents parameter constraints (fd, user buffer,
count), error conditions, locking requirements, signal handling
behavior, and short write semantics.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 fs/read_write.c | 413 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 413 insertions(+)

diff --git a/fs/read_write.c b/fs/read_write.c
index b89c688a41992..a9c07c21f5888 100644
--- a/fs/read_write.c
+++ b/fs/read_write.c
@@ -1041,6 +1041,419 @@ ssize_t ksys_write(unsigned int fd, const char __user *buf, size_t count)
 	return ret;
 }
 
+/**
+ * sys_write - Write data to a file descriptor
+ * @fd: File descriptor to write to
+ * @buf: User-space buffer containing data to write
+ * @count: Maximum number of bytes to write
+ *
+ * long-desc: Writes at most count bytes from the user buffer buf to fd. For
+ *   seekable files (regular files, block devices), the write begins at the
+ *   current file offset, and the file offset is advanced by the number of
+ *   bytes written. If the file was opened with O_APPEND, the file offset is
+ *   first set to the end of the file before writing. For stream files
+ *   (FMODE_STREAM, such as pipes, FIFOs, and sockets), the file offset is not
+ *   used and writing occurs at the position defined by the device. Other
+ *   files, including many character devices, keep a file offset although a
+ *   driver may ignore it.
+ *
+ *   Fewer than count bytes may be written, for example when the filesystem
+ *   runs out of space, the write reaches RLIMIT_FSIZE, or a signal arrives
+ *   after some data was written. The caller writes the rest with another
+ *   write() call.
+ *
+ *   On Linux, write() transfers at most MAX_RW_COUNT (INT_MAX & PAGE_MASK,
+ *   0x7ffff000 with 4KB pages, just under 2GB) bytes per call, regardless of
+ *   whether the file or filesystem would allow more. This prevents signed
+ *   arithmetic overflow.
+ *
+ *   For regular files, a successful write() does not guarantee that data has been
+ *   committed to disk. Use fsync(2) or fdatasync(2) if durability is required.
+ *   For O_SYNC or O_DSYNC files, the kernel automatically syncs data on write.
+ *
+ *   POSIX permits writes that are interrupted after partial writes to either
+ *   return -1 with errno=EINTR, or to return the count of bytes already written.
+ *   Linux implements the latter behavior: if some data has been written before
+ *   a signal arrives, write() returns the number of bytes written rather than
+ *   failing with EINTR.
+ *
+ * contexts: process, sleepable
+ *
+ * param: fd
+ *   type: fd, input
+ *   constraint-type: range(0, INT_MAX)
+ *   cdesc: Must be a valid, open file descriptor with write permission.
+ *     The file must have been opened with O_WRONLY or O_RDWR. File descriptors
+ *     opened with O_RDONLY, O_PATH, or that have been closed return EBADF.
+ *     Standard file descriptors 0 (stdin), 1 (stdout), 2 (stderr) are valid if
+ *     open and writable. AT_FDCWD and other special values are not valid.
+ *
+ * param: buf
+ *   type: user_ptr, input
+ *   constraint-type: buffer(2)
+ *   cdesc: Must point to a readable user-space region of at least count bytes.
+ *     The range is checked via access_ok() and a range outside user space
+ *     fails with EFAULT. NULL is not rejected by that check. With a count of 0
+ *     the call returns 0 in common cases. With a count above 0 the copy from
+ *     user space fails with EFAULT, or the call returns a short count if part
+ *     of the buffer was copied first. O_DIRECT writes may require block-size
+ *     alignment (see STATX_DIOALIGN).
+ *
+ * param: count
+ *   type: uint, input
+ *   cdesc: Maximum number of bytes to write. The range buf to buf + count must
+ *     lie within user space or access_ok() fails with EFAULT, which includes
+ *     counts that do not fit in ssize_t on 64-bit kernels. On 32-bit kernels
+ *     such a count reaches rw_verify_area() and fails with EINVAL. A count of
+ *     0 is passed to the file's write method, which typically returns 0 but
+ *     may trigger driver-specific side effects. After the checks pass, counts
+ *     above MAX_RW_COUNT (INT_MAX & PAGE_MASK, 0x7ffff000 with 4KB pages) are
+ *     clamped.
+ *
+ * return:
+ *   type: int
+ *   check-type: range
+ *   success: >= 0
+ *   desc: On success, returns the number of bytes written (non-negative). Zero
+ *     indicates that nothing was written (count was 0, or a device-specific
+ *     write method accepted no data). Non-blocking writes that cannot proceed
+ *     return -EAGAIN instead. The return value may be less than count due to
+ *     resource limits, signal interruption, or device constraints (short
+ *     write). On error, returns a negative error code.
+ *
+ * error: EBADF, Bad file descriptor
+ *   desc: fd is not a valid file descriptor, or fd was not opened for writing.
+ *     This includes file descriptors opened with O_RDONLY, O_PATH, or file
+ *     descriptors that have been closed. Also returned if the file structure
+ *     does not have FMODE_WRITE set.
+ *
+ * error: EFAULT, Bad address
+ *   desc: buf points outside the accessible address space. The buffer address
+ *     failed access_ok() validation. Can also occur if a fault happens during
+ *     copy_from_user() when reading data from user space.
+ *
+ * error: EINVAL, Invalid argument
+ *   desc: Returned in several cases: (1) The file has no write or write_iter
+ *     method (FMODE_CAN_WRITE is not set). (2) The file was opened with
+ *     O_DIRECT and the buffer alignment, offset, or count does not meet the
+ *     filesystem's alignment requirements. (3) rw_verify_area() rejects a
+ *     negative file position, or a position plus count that overflows, on a
+ *     file without FOP_UNSIGNED_OFFSET. (4) The count, cast to ssize_t, is
+ *     negative (32-bit kernels only, as 64-bit kernels fail access_ok() first
+ *     with EFAULT).
+ *
+ * error: EAGAIN, Resource temporarily unavailable
+ *   desc: fd refers to a file (pipe, socket, device) that is marked non-blocking
+ *     (O_NONBLOCK) and the write would block because the buffer is full.
+ *     Equivalent to EWOULDBLOCK. The application should retry later or use
+ *     select/poll/epoll to wait for writability.
+ *
+ * error: EINTR, Interrupted system call
+ *   desc: The call was interrupted by a signal before any data was written. This
+ *     only occurs if no data has been transferred; if some data was written
+ *     before the signal, the call returns the number of bytes written. The
+ *     caller should typically restart the write.
+ *
+ * error: EPIPE, Broken pipe
+ *   desc: fd refers to a pipe or socket whose reading end has been closed.
+ *     When this condition occurs, the calling process also receives a SIGPIPE
+ *     signal. If the signal is caught or ignored, EPIPE is still returned.
+ *     For sockets, MSG_NOSIGNAL (via send()) suppresses the signal. For
+ *     pwritev2(), the RWF_NOSIGNAL flag suppresses it.
+ *
+ * error: EFBIG, File too large
+ *   desc: The write starts at or beyond a file size limit. The generic write
+ *     path returns EFBIG when the starting position is at or beyond
+ *     RLIMIT_FSIZE, in which case the process also receives SIGXFSZ, or at or
+ *     beyond the maximum file size of the filesystem. Without O_LARGEFILE
+ *     (32-bit systems only) that maximum is 2GB minus one byte. A write that
+ *     starts below a limit but would cross it is shortened to end at the
+ *     limit.
+ *
+ * error: ENOSPC, No space left on device
+ *   desc: The device containing the file has no room for the data. This can
+ *     occur mid-write resulting in a short write followed by ENOSPC on retry.
+ *
+ * error: EDQUOT, Disk quota exceeded
+ *   desc: The user's quota of disk blocks on the filesystem has been exhausted.
+ *     Like ENOSPC, this can result in a short write.
+ *
+ * error: EIO, Input/output error
+ *   desc: A low-level I/O error occurred while modifying the inode or writing
+ *     data. This typically indicates hardware failure, filesystem corruption,
+ *     or network filesystem timeout. Some data may have been written.
+ *
+ * error: EPERM, Operation not permitted
+ *   desc: The operation was prevented (1) by a file seal (F_SEAL_WRITE or
+ *     F_SEAL_FUTURE_WRITE on memfd/shmem, or F_SEAL_GROW for a write that
+ *     extends the file), (2) by a filesystem-specific check, for example ext4
+ *     refuses writes to an immutable inode, (3) because the file is a
+ *     read-only block device, (4) by an LSM hook denying the operation, or (5)
+ *     by a fanotify pre-content (FAN_PRE_ACCESS) listener denying the write.
+ *
+ * error: EOVERFLOW, Value too large for defined data type
+ *   desc: Returned by rw_verify_area() only for files with FOP_UNSIGNED_OFFSET
+ *     (for example /proc/pid/mem) when the file position is negative, meaning
+ *     above LLONG_MAX, and count is at least -pos so that the write would wrap
+ *     past the end of the 64-bit offset space. Files without
+ *     FOP_UNSIGNED_OFFSET get EINVAL for a negative position or a position
+ *     plus count that overflows. Exceeding filesystem file size limits is
+ *     EFBIG, not EOVERFLOW.
+ *
+ * error: EDESTADDRREQ, Destination address required
+ *   desc: fd is a datagram socket for which no peer address has been set using
+ *     connect(2). Use sendto(2) to specify the destination address.
+ *
+ * error: ETXTBSY, Text file busy
+ *   desc: The file is being used as a swap file (IS_SWAPFILE).
+ *
+ * error: EXDEV, Cross-device link
+ *   desc: When writing to a pipe that has been configured as a watch queue
+ *     (CONFIG_WATCH_QUEUE), direct write() calls are not supported.
+ *
+ * error: ENOMEM, Out of memory
+ *   desc: Insufficient kernel memory was available for the write operation.
+ *     For pipes, this occurs when allocating pages for the pipe buffer.
+ *
+ * error: ERESTARTSYS, Restart system call (internal)
+ *   desc: Internal error code indicating the syscall should be restarted. This
+ *     is converted to EINTR if SA_RESTART is not set on the signal handler, or
+ *     the syscall is transparently restarted if SA_RESTART is set. User space
+ *     should not see this error code directly.
+ *
+ * error: EACCES, Permission denied
+ *   desc: The security subsystem (LSM such as SELinux or AppArmor) denied the
+ *     write operation via security_file_permission(). This can occur even if
+ *     the file was successfully opened.
+ *
+ * lock: file->f_pos_lock
+ *   type: mutex
+ *   acquired: true
+ *   released: true
+ *   desc: For regular files that require atomic position updates (FMODE_ATOMIC_POS),
+ *     the f_pos_lock mutex is acquired by fdget_pos() at syscall entry and released
+ *     by fdput_pos() at syscall exit. This serializes concurrent writes sharing
+ *     the same file description. Not acquired for stream files (FMODE_STREAM like
+ *     pipes and sockets) or when the file is not shared.
+ *
+ * lock: sb->s_writers (freeze protection)
+ *   type: custom
+ *   acquired: true
+ *   released: true
+ *   desc: For regular files, file_start_write() acquires freeze protection on
+ *     the superblock via sb_start_write() before the write, and file_end_write()
+ *     releases it after. This prevents writes during filesystem freeze. Not
+ *     acquired for non-regular files (pipes, sockets, devices).
+ *
+ * lock: inode->i_rwsem
+ *   type: semaphore
+ *   acquired: true
+ *   released: true
+ *   desc: For regular files using generic_file_write_iter(), the inode's i_rwsem
+ *     is acquired in write mode before modifying file data. This is internal to
+ *     the filesystem and released before return. Not all filesystems use this
+ *     pattern.
+ *
+ * lock: pipe->mutex
+ *   type: mutex
+ *   acquired: true
+ *   released: true
+ *   desc: For pipes and FIFOs, the pipe's mutex is held while modifying pipe
+ *     buffers. Released temporarily while waiting for space, then reacquired.
+ *
+ * lock: RCU read-side
+ *   type: rcu
+ *   acquired: true
+ *   released: true
+ *   desc: Taken only when the files_struct is shared with other threads, in
+ *     which case the fd lookup in fdget() uses RCU and takes a file reference.
+ *     A private table needs no RCU. The RCU read lock is acquired and released
+ *     internally by the fd lookup path, not held across the entire syscall.
+ *     fdput() releases the file reference count, not the RCU lock.
+ *
+ * signal: SIGPIPE
+ *   number: SIGPIPE
+ *   direction: send
+ *   action: terminate
+ *   condition: Writing to a pipe or socket with no readers
+ *   desc: When writing to a pipe whose read end is closed, or a socket whose
+ *     peer has closed, SIGPIPE is sent to the calling process. The default
+ *     action terminates the process. Use signal(SIGPIPE, SIG_IGN) to suppress
+ *     for write(). EPIPE is returned regardless of signal disposition.
+ *   timing: during
+ *
+ * signal: SIGXFSZ
+ *   number: SIGXFSZ
+ *   direction: send
+ *   action: coredump
+ *   condition: Write starts at or beyond RLIMIT_FSIZE
+ *   desc: When a write starts at or beyond the soft file size limit
+ *     (RLIMIT_FSIZE), generic_write_check_limits() sends SIGXFSZ and the write
+ *     returns EFBIG. The default action terminates with a core dump. A write
+ *     that starts below the limit but would cross it is shortened to end at
+ *     the limit, with no signal. If RLIMIT_FSIZE is RLIM_INFINITY, no signal
+ *     is sent.
+ *   timing: during
+ *
+ * signal: Any signal
+ *   direction: receive
+ *   action: return
+ *   condition: While blocked waiting for space (pipes, sockets)
+ *   desc: The syscall may be interrupted by signals while waiting for buffer
+ *     space to become available. If interrupted before any data is written,
+ *     returns -EINTR or -ERESTARTSYS. If data was already written, returns the
+ *     byte count. Restartable if SA_RESTART is set and no data was written.
+ *   errno: -EINTR
+ *   timing: during
+ *   restartable: yes
+ *
+ * side-effect: file_position
+ *   target: file->f_pos
+ *   condition: For seekable files when write succeeds (returns > 0)
+ *   desc: The file offset (f_pos) is advanced by the number of bytes written.
+ *     For files opened with O_APPEND, f_pos is first set to file size. For
+ *     stream files (FMODE_STREAM such as pipes and sockets), the offset is not
+ *     used or modified. Position updates are protected by f_pos_lock when
+ *     shared.
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: inode timestamps (mtime, ctime)
+ *   condition: Before the data is copied, for a non-zero count on the generic
+ *     write path
+ *   desc: Updates the file's modification time (mtime) and change time (ctime)
+ *     via file_update_time(), which runs before the data is copied, so the
+ *     timestamps can change even if the write then fails or is short. The
+ *     update is skipped for inodes flagged NOCMTIME and when the timestamps
+ *     are unchanged. The timestamp precision depends on whether the filesystem
+ *     type sets FS_MGTIME (multigrain timestamps).
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: SUID/SGID bits (mode) and file capabilities
+ *   condition: Write to a regular file that has S_ISUID, S_ISGID, or file
+ *     capabilities set, with a non-zero count
+ *   desc: file_remove_privs() clears S_ISUID, and clears S_ISGID when S_IXGRP
+ *     is also set or the caller is not in the file's group, unless the caller
+ *     has CAP_FSETID. File capabilities (the security.capability xattr) are
+ *     removed as well, regardless of CAP_FSETID. This is a security feature to
+ *     prevent privilege escalation via modified setuid binaries. It runs before
+ *     the data is copied.
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: file data
+ *   condition: When write succeeds (returns > 0)
+ *   desc: Modifies the file's data content. For regular files, data is written
+ *     to the page cache (buffered I/O) or directly to storage (O_DIRECT).
+ *     Data is not guaranteed to be persistent until fsync() or fdatasync()
+ *     completes, or writeback has written it to storage.
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: task I/O accounting
+ *   condition: When CONFIG_TASK_XACCT is enabled and the write method is
+ *     invoked
+ *   desc: Updates the current task's I/O accounting statistics. The wchar field
+ *     (write characters) is incremented by bytes written via add_wchar() only on
+ *     successful writes (ret > 0). The syscw field (syscall write count) is
+ *     incremented via inc_syscw() once the write method has been invoked, even
+ *     if it fails, but not when the FMODE_WRITE, FMODE_CAN_WRITE, access_ok()
+ *     or rw_verify_area() checks fail. These statistics are visible in
+ *     /proc/[pid]/io.
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: fsnotify events
+ *   condition: When write returns > 0
+ *   desc: Generates an FS_MODIFY fsnotify event via fsnotify_modify(), allowing
+ *     inotify, fanotify, and dnotify watchers to be notified of the write.
+ *
+ * capability: CAP_FSETID
+ *   type: bypass_check
+ *   allows: Keep the SUID and SGID bits set when the file is written
+ *   without: SUID is cleared on write, and SGID is cleared if S_IXGRP is set
+ *     or the caller is not in the file's group
+ *   condition: Checked in setattr_should_drop_suidgid() during
+ *     file_remove_privs()
+ *
+ * constraint: MAX_RW_COUNT
+ *   desc: After the access_ok() and rw_verify_area() checks pass, the count
+ *     parameter is silently clamped to MAX_RW_COUNT (INT_MAX & PAGE_MASK, just
+ *     under 2GB) to prevent integer overflow in internal calculations. This is
+ *     transparent to the caller.
+ *   expr: actual_count = min(count, MAX_RW_COUNT)
+ *
+ * constraint: File must be open for writing
+ *   desc: The file descriptor must have been opened with O_WRONLY or O_RDWR.
+ *     Files opened with O_RDONLY or O_PATH cannot be written and return EBADF.
+ *     The file must have both FMODE_WRITE and FMODE_CAN_WRITE flags set.
+ *   expr: (file->f_mode & FMODE_WRITE) && (file->f_mode & FMODE_CAN_WRITE)
+ *
+ * constraint: RLIMIT_FSIZE
+ *   desc: The size of data written is constrained by the RLIMIT_FSIZE resource
+ *     limit. In generic_write_check_limits(), a write that starts at or beyond
+ *     the limit sends SIGXFSZ and returns EFBIG. A write that starts below the
+ *     limit but would cross it is shortened to limit - pos bytes, with no
+ *     signal.
+ *   expr: pos < rlimit(RLIMIT_FSIZE) || rlimit(RLIMIT_FSIZE) == RLIM_INFINITY
+ *
+ * constraint: File seals
+ *   desc: For memfd or shmem files with F_SEAL_WRITE or F_SEAL_FUTURE_WRITE
+ *     seals applied, all write operations fail with EPERM. With F_SEAL_GROW,
+ *     writes that would extend file size fail with EPERM.
+ *
+ * examples: n = write(fd, buf, sizeof(buf));  // Basic write
+ *   n = write(STDOUT_FILENO, msg, strlen(msg));  // Write to stdout
+ *   // Handle short writes:
+ *   while (total < len) {
+ *     n = write(fd, buf + total, len - total);
+ *     if (n < 0) break;
+ *     total += n;
+ *   }
+ *   // Pipe error handling:
+ *   if (write(pipefd[1], &byte, 1) < 0 && errno == EPIPE)
+ *     handle_broken_pipe();
+ *
+ * notes: The behavior of write() varies significantly depending on the type of
+ *   file descriptor:
+ *
+ *   - Regular files: Writes to the page cache (buffered) or directly to storage
+ *     (O_DIRECT). Short writes are rare except near RLIMIT_FSIZE or disk full.
+ *     O_APPEND is atomic for determining write position.
+ *
+ *   - Pipes and FIFOs: Blocking by default. Writes up to PIPE_BUF (4096 bytes
+ *     on Linux) are guaranteed atomic. Larger writes may be interleaved with
+ *     writes from other processes. Blocks if pipe is full; returns EAGAIN with
+ *     O_NONBLOCK. SIGPIPE/EPIPE if no readers.
+ *
+ *   - Sockets: Behavior depends on socket type and protocol. Stream sockets
+ *     (TCP) may return partial writes. Datagram sockets (UDP) typically write
+ *     complete messages or fail. SIGPIPE/EPIPE for broken connections (unless
+ *     MSG_NOSIGNAL). EDESTADDRREQ for unconnected datagram sockets.
+ *
+ *   - Terminals: May block on flow control. Canonical vs raw mode affects
+ *     behavior. Special characters may be interpreted.
+ *
+ *   - Device special files: Behavior is device-specific. Block devices behave
+ *     similarly to regular files. Character device behavior varies.
+ *
+ *   Race condition considerations: Concurrent writes from threads sharing a
+ *   file description race on the file position. Linux 3.14+ provides atomic
+ *   position updates via f_pos_lock for regular files (FMODE_ATOMIC_POS), but
+ *   for maximum safety, use pwrite() for concurrent positioned writes.
+ *
+ *   O_DIRECT writes bypass the page cache and typically require buffer and
+ *   offset alignment to filesystem block size. Query requirements via statx()
+ *   with STATX_DIOALIGN (Linux 6.1+). Unaligned O_DIRECT writes return EINVAL
+ *   on most filesystems.
+ *
+ *   For zero-copy writes, consider using splice(2), sendfile(2), or vmsplice(2)
+ *   instead of copying data through user-space buffers with write().
+ *
+ *   Partial writes (short writes) must be handled by application code.
+ *   Applications should loop until all data is written or an error occurs.
+ */
 SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
 		size_t, count)
 {
-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* [PATCH v5 09/11] kernel/api: add runtime verification selftest
  2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
                   ` (7 preceding siblings ...)
  2026-10-08  8:49 ` [PATCH v5 08/11] kernel/api: add API specification for sys_write Sasha Levin
@ 2026-10-08  8:49 ` Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 10/11] kernel/api: add API specification for sys_madvise Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 11/11] kernel/api: add syscall enter/exit tracepoints Sasha Levin
  10 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

Add a selftest for CONFIG_KAPI_RUNTIME_CHECKS that exercises
sys_open/sys_read/sys_write/sys_close through raw syscall() and
verifies KAPI pre-validation catches invalid parameters while
allowing valid operations through.

Test cases (TAP output):
  1-4:   Valid open/read/write/close succeed
  5-8:   Unknown open flag bits and a NULL path are rejected with EINVAL,
         while mode bits above 07777 are accepted (the kernel masks them)
  9-20:  Boundary conditions and error paths (bad and closed fds, zero
         counts, over-long paths, unmapped buffers, wrong access mode)
  21-27: Pipe, file descriptor lifecycle and other error paths
  28-30: A normal open/read/write/close sequence, and read()/write() with
         a NULL buffer and a zero count returning 0
  31:    dmesg contains the expected KAPI warning strings

Tests that depend on CONFIG_KAPI_RUNTIME_CHECKS are skipped when an open()
with an unknown flag bit succeeds, which means the checks are not active.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 MAINTAINERS                                   |    1 +
 tools/testing/selftests/Makefile              |    1 +
 tools/testing/selftests/kapi/.gitignore       |    2 +
 tools/testing/selftests/kapi/Makefile         |    7 +
 tools/testing/selftests/kapi/kapi_test_util.h |   31 +
 tools/testing/selftests/kapi/test_kapi.c      | 1163 +++++++++++++++++
 6 files changed, 1205 insertions(+)
 create mode 100644 tools/testing/selftests/kapi/.gitignore
 create mode 100644 tools/testing/selftests/kapi/Makefile
 create mode 100644 tools/testing/selftests/kapi/kapi_test_util.h
 create mode 100644 tools/testing/selftests/kapi/test_kapi.c

diff --git a/MAINTAINERS b/MAINTAINERS
index cd15c6a776ae3..1d9e55b6bf5ff 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -14128,6 +14128,7 @@ F:	include/linux/kernel_api_spec.h
 F:	kernel/api/
 F:	tools/kapi/
 F:	tools/lib/python/kdoc/kdoc_apispec.py
+F:	tools/testing/selftests/kapi/
 
 KERNEL AUTOMOUNTER
 M:	Ian Kent <raven@themaw.net>
diff --git a/tools/testing/selftests/Makefile b/tools/testing/selftests/Makefile
index 273853937c254..d5f973eeefc83 100644
--- a/tools/testing/selftests/Makefile
+++ b/tools/testing/selftests/Makefile
@@ -60,6 +60,7 @@ TARGETS += intel_pstate
 TARGETS += iommu
 TARGETS += ipc
 TARGETS += ir
+TARGETS += kapi
 TARGETS += kcmp
 TARGETS += kexec
 TARGETS += kselftest_harness
diff --git a/tools/testing/selftests/kapi/.gitignore b/tools/testing/selftests/kapi/.gitignore
new file mode 100644
index 0000000000000..e60893d183d81
--- /dev/null
+++ b/tools/testing/selftests/kapi/.gitignore
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: GPL-2.0-only
+test_kapi
diff --git a/tools/testing/selftests/kapi/Makefile b/tools/testing/selftests/kapi/Makefile
new file mode 100644
index 0000000000000..d1bb26541c0b8
--- /dev/null
+++ b/tools/testing/selftests/kapi/Makefile
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: GPL-2.0
+
+TEST_GEN_PROGS := test_kapi
+
+CFLAGS += -static -Wall -Wextra -O2 $(KHDR_INCLUDES)
+
+include ../lib.mk
diff --git a/tools/testing/selftests/kapi/kapi_test_util.h b/tools/testing/selftests/kapi/kapi_test_util.h
new file mode 100644
index 0000000000000..a09871207bfc6
--- /dev/null
+++ b/tools/testing/selftests/kapi/kapi_test_util.h
@@ -0,0 +1,31 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+/*
+ * Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+ *
+ * Compatibility helpers for KAPI selftests.
+ *
+ * __NR_open does not exist on architectures using the generic syscall table
+ * (e.g. arm64, riscv); only __NR_openat does. There the wrapper falls back to
+ * openat(AT_FDCWD, ...), which does not go through sys_open, so the sys_open
+ * KAPI checks are not exercised.
+ */
+#ifndef KAPI_TEST_UTIL_H
+#define KAPI_TEST_UTIL_H
+
+#include <fcntl.h>
+#include <unistd.h>
+#include <sys/syscall.h>
+
+#ifndef __NR_open
+static inline long kapi_sys_open(const char *pathname, int flags, int mode)
+{
+	return syscall(__NR_openat, AT_FDCWD, pathname, flags, mode);
+}
+#else
+static inline long kapi_sys_open(const char *pathname, int flags, int mode)
+{
+	return syscall(__NR_open, pathname, flags, mode);
+}
+#endif
+
+#endif /* KAPI_TEST_UTIL_H */
diff --git a/tools/testing/selftests/kapi/test_kapi.c b/tools/testing/selftests/kapi/test_kapi.c
new file mode 100644
index 0000000000000..deccb075ee1f9
--- /dev/null
+++ b/tools/testing/selftests/kapi/test_kapi.c
@@ -0,0 +1,1163 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+ *
+ * Userspace selftest for KAPI runtime verification of syscall parameters.
+ *
+ * Exercises sys_open, sys_read, sys_write, and sys_close through raw
+ * syscall() to ensure KAPI pre-validation wrappers interact correctly
+ * with normal kernel error handling.
+ *
+ * Requires CONFIG_KAPI_RUNTIME_CHECKS=y for full coverage; many tests
+ * also pass without it.
+ *
+ * TAP output format.
+ */
+
+#define _GNU_SOURCE
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <signal.h>
+#include <sys/syscall.h>
+#include <sys/stat.h>
+#include <linux/limits.h>
+#include "../kselftest.h"
+#include "kapi_test_util.h"
+
+#define NUM_TESTS 31
+
+/* Set from the SIGPIPE handler. */
+static volatile sig_atomic_t got_sigpipe;
+
+static void tap_ok(const char *desc)
+{
+	ksft_test_result_pass("%s\n", desc);
+}
+
+static void tap_fail(const char *desc, const char *reason)
+{
+	ksft_test_result_fail("%s: %s\n", desc, reason);
+}
+
+static void tap_skip(const char *desc, const char *reason)
+{
+	ksft_test_result_skip("%s: %s\n", desc, reason);
+}
+
+/*
+ * Return true when CONFIG_KAPI_RUNTIME_CHECKS is active: it rejects unknown
+ * open flag bits with EINVAL, while the kernel otherwise ignores them.
+ */
+static bool kapi_runtime_checks_active(void)
+{
+	long ret;
+
+	errno = 0;
+	ret = kapi_sys_open("/dev/null", 0x10000000, 0);
+	if (ret >= 0) {
+		syscall(__NR_close, (int)ret);
+		return false;
+	}
+
+	return errno == EINVAL;
+}
+
+static void sigpipe_handler(int sig)
+{
+	(void)sig;
+	got_sigpipe = 1;
+}
+
+/* ---- Valid operation tests ---- */
+
+/*
+ * Test 1: open a readable file
+ * Returns fd on success.
+ */
+static int test_open_valid(void)
+{
+	errno = 0;
+	long fd = kapi_sys_open("/etc/hostname", O_RDONLY, 0);
+
+	if (fd >= 0) {
+		tap_ok("open valid file");
+	} else {
+		/* /etc/hostname might not exist; try /etc/passwd */
+		errno = 0;
+		fd = kapi_sys_open("/etc/passwd", O_RDONLY, 0);
+		if (fd >= 0)
+			tap_ok("open valid file (fallback /etc/passwd)");
+		else
+			tap_fail("open valid file", strerror(errno));
+	}
+	return (int)fd;
+}
+
+/*
+ * Test 2: read from fd
+ */
+static void test_read_valid(int fd)
+{
+	char buf[256];
+
+	errno = 0;
+	long ret = syscall(__NR_read, fd, buf, sizeof(buf));
+
+	if (ret > 0)
+		tap_ok("read from valid fd");
+	else if (ret == 0)
+		tap_ok("read from valid fd (EOF)");
+	else
+		tap_fail("read from valid fd", strerror(errno));
+}
+
+/*
+ * Test 3: write to /dev/null
+ */
+static void test_write_valid(void)
+{
+	errno = 0;
+	long devnull = kapi_sys_open("/dev/null", O_WRONLY, 0);
+
+	if (devnull < 0) {
+		tap_fail("write to /dev/null (open failed)", strerror(errno));
+		return;
+	}
+
+	errno = 0;
+	long ret = syscall(__NR_write, (int)devnull, "hello", 5);
+
+	if (ret == 5)
+		tap_ok("write to /dev/null");
+	else
+		tap_fail("write to /dev/null",
+			 ret < 0 ? strerror(errno) : "short write");
+
+	syscall(__NR_close, (int)devnull);
+}
+
+/*
+ * Test 4: close fd
+ */
+static void test_close_valid(int fd)
+{
+	errno = 0;
+	long ret = syscall(__NR_close, fd);
+
+	if (ret == 0)
+		tap_ok("close valid fd");
+	else
+		tap_fail("close valid fd", strerror(errno));
+}
+
+/* ---- KAPI parameter rejection tests ---- */
+
+/*
+ * Test 5: open with invalid flag bits
+ * 0x10000000 is outside the valid O_* mask, KAPI should reject.
+ */
+static void test_open_invalid_flags(void)
+{
+	long ret;
+
+	if (!kapi_runtime_checks_active()) {
+		tap_skip("open with invalid flags",
+			 "CONFIG_KAPI_RUNTIME_CHECKS not enabled");
+		return;
+	}
+
+	errno = 0;
+	/*
+	 * Use /dev/null (always present on any sane rootfs) so KAPI's flag
+	 * validation is reached before a path-lookup ENOENT can mask it.
+	 * 0x10000000 is outside the valid O_* mask.
+	 */
+	ret = kapi_sys_open("/dev/null", 0x10000000, 0);
+
+	if (ret == -1 && errno == EINVAL) {
+		tap_ok("open with invalid flags returns EINVAL");
+	} else if (ret >= 0) {
+		tap_fail("open with invalid flags", "expected EINVAL, got success");
+		syscall(__NR_close, (int)ret);
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected EINVAL, got %s",
+			 strerror(errno));
+		tap_fail("open with invalid flags", msg);
+	}
+}
+
+/*
+ * Test 6: open with mode bits above S_IALLUGO
+ * The kernel masks mode with S_IALLUGO (07777), so KAPI must accept it.
+ */
+static void test_open_high_mode_bits(void)
+{
+	const char *desc = "open with mode bits above 07777 creates file";
+	char path[64];
+	struct stat st;
+	mode_t um, want;
+	long fd;
+
+	snprintf(path, sizeof(path), "/tmp/kapi_test_mode.%d", getpid());
+	unlink(path);
+
+	um = umask(0);
+	umask(um);
+	want = 07777 & ~um;
+
+	errno = 0;
+	fd = kapi_sys_open(path, O_CREAT | O_WRONLY | O_EXCL, 0xFFFF);
+	if (fd < 0) {
+		tap_fail(desc, strerror(errno));
+		return;
+	}
+
+	if (fstat((int)fd, &st) < 0) {
+		tap_fail(desc, strerror(errno));
+	} else if ((st.st_mode & 07777) != want) {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected mode %04o, got %04o",
+			 (unsigned int)want, (unsigned int)(st.st_mode & 07777));
+		tap_fail(desc, msg);
+	} else {
+		tap_ok(desc);
+	}
+
+	syscall(__NR_close, (int)fd);
+	unlink(path);
+}
+
+/*
+ * Test 7: open with NULL path
+ * KAPI USER_PATH constraint should reject NULL.
+ */
+static void test_open_null_path(void)
+{
+	errno = 0;
+	long ret = kapi_sys_open(NULL, O_RDONLY, 0);
+
+	if (ret == -1 && errno == EINVAL) {
+		tap_ok("open with NULL path returns EINVAL");
+	} else if (ret == -1 && errno == EFAULT) {
+		/* Kernel may catch this as EFAULT before KAPI */
+		tap_ok("open with NULL path returns EFAULT (acceptable)");
+	} else if (ret >= 0) {
+		tap_fail("open with NULL path", "expected error, got success");
+		syscall(__NR_close, (int)ret);
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "got %s", strerror(errno));
+		tap_fail("open with NULL path", msg);
+	}
+}
+
+/*
+ * Test 8: open with flag bit 30 set (0x40000000)
+ * This bit is outside the valid O_* mask, KAPI should reject with EINVAL.
+ */
+static void test_open_flag_bit30(void)
+{
+	long ret;
+
+	if (!kapi_runtime_checks_active()) {
+		tap_skip("open with flag bit 30 (0x40000000) returns EINVAL",
+			 "CONFIG_KAPI_RUNTIME_CHECKS not enabled");
+		return;
+	}
+
+	errno = 0;
+	ret = kapi_sys_open("/dev/null", 0x40000000, 0);
+
+	if (ret == -1 && errno == EINVAL) {
+		tap_ok("open with flag bit 30 (0x40000000) returns EINVAL");
+	} else if (ret >= 0) {
+		tap_fail("open with flag bit 30 (0x40000000) returns EINVAL",
+			 "expected EINVAL, got success");
+		syscall(__NR_close, (int)ret);
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected EINVAL, got %s",
+			 strerror(errno));
+		tap_fail("open with flag bit 30 (0x40000000) returns EINVAL",
+			 msg);
+	}
+}
+
+/* ---- Boundary condition and error path tests ---- */
+
+/*
+ * Test 9: read with fd=-1 should return an error.
+ * With CONFIG_KAPI_RUNTIME_CHECKS=y, KAPI validates the fd first and
+ * rejects negative fds (other than AT_FDCWD) with EINVAL.  Without
+ * KAPI, the kernel returns EBADF.  Accept either.
+ */
+static void test_read_bad_fd(void)
+{
+	char buf[16];
+
+	errno = 0;
+	long ret = syscall(__NR_read, -1, buf, sizeof(buf));
+
+	if (ret == -1 && (errno == EBADF || errno == EINVAL)) {
+		tap_ok("read with fd=-1 returns error");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected EBADF/EINVAL, got %s",
+			 ret >= 0 ? "success" : strerror(errno));
+		tap_fail("read with fd=-1 returns error", msg);
+	}
+}
+
+/*
+ * Test 10: read with count=0 should return 0
+ */
+static void test_read_zero_count(void)
+{
+	char buf[1];
+	long fd;
+
+	errno = 0;
+	fd = kapi_sys_open("/dev/null", O_RDONLY, 0);
+	if (fd < 0) {
+		tap_fail("read with count=0 returns 0",
+			 "cannot open /dev/null");
+		return;
+	}
+
+	errno = 0;
+	long ret = syscall(__NR_read, (int)fd, buf, 0);
+
+	if (ret == 0) {
+		tap_ok("read with count=0 returns 0");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected 0, got %ld (errno=%s)",
+			 ret, strerror(errno));
+		tap_fail("read with count=0 returns 0", msg);
+	}
+
+	syscall(__NR_close, (int)fd);
+}
+
+/*
+ * Test 11: write with count=0 should return 0
+ */
+static void test_write_zero_count(void)
+{
+	long fd;
+
+	errno = 0;
+	fd = kapi_sys_open("/dev/null", O_WRONLY, 0);
+	if (fd < 0) {
+		tap_fail("write with count=0 returns 0",
+			 "cannot open /dev/null");
+		return;
+	}
+
+	errno = 0;
+	long ret = syscall(__NR_write, (int)fd, "x", 0);
+
+	if (ret == 0) {
+		tap_ok("write with count=0 returns 0");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected 0, got %ld (errno=%s)",
+			 ret, strerror(errno));
+		tap_fail("write with count=0 returns 0", msg);
+	}
+
+	syscall(__NR_close, (int)fd);
+}
+
+/*
+ * Test 12: open with a path longer than PATH_MAX should fail
+ * Expect ENAMETOOLONG or EINVAL.
+ */
+static void test_open_long_path(void)
+{
+	char *longpath;
+	size_t len = PATH_MAX + 256;
+
+	longpath = malloc(len);
+	if (!longpath) {
+		tap_fail("open with path > PATH_MAX", "malloc failed");
+		return;
+	}
+
+	memset(longpath, 'A', len - 1);
+	longpath[0] = '/';
+	longpath[len - 1] = '\0';
+
+	errno = 0;
+	long ret = kapi_sys_open(longpath, O_RDONLY, 0);
+
+	if (ret == -1 && (errno == ENAMETOOLONG || errno == EINVAL)) {
+		tap_ok("open with path > PATH_MAX returns ENAMETOOLONG/EINVAL");
+	} else if (ret >= 0) {
+		tap_fail("open with path > PATH_MAX",
+			 "expected error, got success");
+		syscall(__NR_close, (int)ret);
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg),
+			 "expected ENAMETOOLONG/EINVAL, got %s",
+			 strerror(errno));
+		tap_fail("open with path > PATH_MAX", msg);
+	}
+
+	free(longpath);
+}
+
+/*
+ * Test 13: read with unmapped user pointer should return EFAULT or EINVAL.
+ * Use a pipe with data so the kernel actually tries to copy to the buffer.
+ */
+static void test_read_unmapped_buf(void)
+{
+	int pipefd[2];
+
+	if (pipe(pipefd) < 0) {
+		tap_fail("read with unmapped buffer returns EFAULT/EINVAL",
+			 "pipe() failed");
+		return;
+	}
+
+	/* Write some data so read has something to copy */
+	if (write(pipefd[1], "hello", 5) != 5) {
+		tap_fail("read with unmapped buffer returns EFAULT/EINVAL",
+			 "pipe write failed");
+		close(pipefd[0]);
+		close(pipefd[1]);
+		return;
+	}
+
+	errno = 0;
+	long ret = syscall(__NR_read, pipefd[0], (void *)0xDEAD0000, 16);
+
+	if (ret == -1 && (errno == EFAULT || errno == EINVAL)) {
+		tap_ok("read with unmapped buffer returns EFAULT/EINVAL");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg),
+			 "expected EFAULT/EINVAL, got %s",
+			 ret >= 0 ? "success" : strerror(errno));
+		tap_fail("read with unmapped buffer returns EFAULT/EINVAL",
+			 msg);
+	}
+
+	close(pipefd[0]);
+	close(pipefd[1]);
+}
+
+/*
+ * Test 14: write with unmapped user pointer should return EFAULT or EINVAL.
+ * Use a pipe so the kernel actually tries to copy from the buffer.
+ */
+static void test_write_unmapped_buf(void)
+{
+	int pipefd[2];
+
+	if (pipe(pipefd) < 0) {
+		tap_fail("write with unmapped buffer returns EFAULT/EINVAL",
+			 "pipe() failed");
+		return;
+	}
+
+	errno = 0;
+	long ret = syscall(__NR_write, pipefd[1], (void *)0xDEAD0000, 16);
+
+	if (ret == -1 && (errno == EFAULT || errno == EINVAL)) {
+		tap_ok("write with unmapped buffer returns EFAULT/EINVAL");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg),
+			 "expected EFAULT/EINVAL, got %s",
+			 ret >= 0 ? "success" : strerror(errno));
+		tap_fail("write with unmapped buffer returns EFAULT/EINVAL",
+			 msg);
+	}
+
+	close(pipefd[0]);
+	close(pipefd[1]);
+}
+
+/*
+ * Test 15: close an already-closed fd should return EBADF
+ */
+static void test_close_already_closed(void)
+{
+	long fd;
+
+	errno = 0;
+	fd = kapi_sys_open("/dev/null", O_RDONLY, 0);
+	if (fd < 0) {
+		tap_fail("close already-closed fd returns EBADF",
+			 "cannot open /dev/null");
+		return;
+	}
+
+	/* Close it once - should succeed */
+	syscall(__NR_close, (int)fd);
+
+	/* Close it again - should fail with EBADF */
+	errno = 0;
+	long ret = syscall(__NR_close, (int)fd);
+
+	if (ret == -1 && errno == EBADF) {
+		tap_ok("close already-closed fd returns EBADF");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected EBADF, got %s",
+			 ret == 0 ? "success" : strerror(errno));
+		tap_fail("close already-closed fd returns EBADF", msg);
+	}
+}
+
+/*
+ * Test 16: open /dev/null with O_RDONLY|O_CLOEXEC should succeed
+ */
+static void test_open_valid_cloexec(void)
+{
+	errno = 0;
+	long fd = kapi_sys_open("/dev/null", O_RDONLY | O_CLOEXEC, 0);
+
+	if (fd >= 0) {
+		tap_ok("open /dev/null with O_RDONLY|O_CLOEXEC succeeds");
+		syscall(__NR_close, (int)fd);
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected success, got %s",
+			 strerror(errno));
+		tap_fail("open /dev/null with O_RDONLY|O_CLOEXEC succeeds",
+			 msg);
+	}
+}
+
+/*
+ * Test 17: write 0 bytes to /dev/null should return 0
+ */
+static void test_write_zero_devnull(void)
+{
+	long fd;
+
+	errno = 0;
+	fd = kapi_sys_open("/dev/null", O_WRONLY, 0);
+	if (fd < 0) {
+		tap_fail("write 0 bytes to /dev/null returns 0",
+			 "cannot open /dev/null");
+		return;
+	}
+
+	errno = 0;
+	long ret = syscall(__NR_write, (int)fd, "", 0);
+
+	if (ret == 0) {
+		tap_ok("write 0 bytes to /dev/null returns 0");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected 0, got %ld (errno=%s)",
+			 ret, strerror(errno));
+		tap_fail("write 0 bytes to /dev/null returns 0", msg);
+	}
+
+	syscall(__NR_close, (int)fd);
+}
+
+/*
+ * Test 18: read from a write-only fd should return EBADF
+ */
+static void test_read_writeonly_fd(void)
+{
+	long fd;
+
+	errno = 0;
+	fd = kapi_sys_open("/dev/null", O_WRONLY, 0);
+	if (fd < 0) {
+		tap_fail("read from write-only fd returns EBADF",
+			 "cannot open /dev/null");
+		return;
+	}
+
+	char buf[16];
+
+	errno = 0;
+	long ret = syscall(__NR_read, (int)fd, buf, sizeof(buf));
+
+	if (ret == -1 && errno == EBADF) {
+		tap_ok("read from write-only fd returns EBADF");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected EBADF, got %s",
+			 ret >= 0 ? "success" : strerror(errno));
+		tap_fail("read from write-only fd returns EBADF", msg);
+	}
+
+	syscall(__NR_close, (int)fd);
+}
+
+/*
+ * Test 19: write to a read-only fd should return EBADF
+ */
+static void test_write_readonly_fd(void)
+{
+	long fd;
+
+	errno = 0;
+	fd = kapi_sys_open("/dev/null", O_RDONLY, 0);
+	if (fd < 0) {
+		tap_fail("write to read-only fd returns EBADF",
+			 "cannot open /dev/null");
+		return;
+	}
+
+	errno = 0;
+	long ret = syscall(__NR_write, (int)fd, "hello", 5);
+
+	if (ret == -1 && errno == EBADF) {
+		tap_ok("write to read-only fd returns EBADF");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected EBADF, got %s",
+			 ret >= 0 ? "success" : strerror(errno));
+		tap_fail("write to read-only fd returns EBADF", msg);
+	}
+
+	syscall(__NR_close, (int)fd);
+}
+
+/*
+ * Test 20: close fd 9999 (likely invalid) should return EBADF
+ */
+static void test_close_fd_9999(void)
+{
+	errno = 0;
+	long ret = syscall(__NR_close, 9999);
+
+	if (ret == -1 && errno == EBADF) {
+		tap_ok("close fd 9999 returns EBADF");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected EBADF, got %s",
+			 ret == 0 ? "success" : strerror(errno));
+		tap_fail("close fd 9999 returns EBADF", msg);
+	}
+}
+
+/*
+ * Test 21: read from pipe after write end is closed returns 0 (EOF)
+ */
+static void test_read_closed_pipe(void)
+{
+	int pipefd[2];
+
+	if (pipe(pipefd) < 0) {
+		tap_fail("read from closed pipe returns 0 (EOF)",
+			 "pipe() failed");
+		return;
+	}
+
+	/* Close write end */
+	close(pipefd[1]);
+
+	char buf[16];
+
+	errno = 0;
+	long ret = syscall(__NR_read, pipefd[0], buf, sizeof(buf));
+
+	if (ret == 0) {
+		tap_ok("read from closed pipe returns 0 (EOF)");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected 0, got %ld (errno=%s)",
+			 ret, ret < 0 ? strerror(errno) : "n/a");
+		tap_fail("read from closed pipe returns 0 (EOF)", msg);
+	}
+
+	close(pipefd[0]);
+}
+
+/*
+ * Test 22: write to pipe after read end is closed returns EPIPE + SIGPIPE
+ */
+static void test_write_closed_pipe(void)
+{
+	int pipefd[2];
+	struct sigaction sa, old_sa;
+
+	if (pipe(pipefd) < 0) {
+		tap_fail("write to closed pipe returns EPIPE + SIGPIPE",
+			 "pipe() failed");
+		return;
+	}
+
+	/* Install SIGPIPE handler */
+	memset(&sa, 0, sizeof(sa));
+	sa.sa_handler = sigpipe_handler;
+	sigemptyset(&sa.sa_mask);
+	sigaction(SIGPIPE, &sa, &old_sa);
+
+	got_sigpipe = 0;
+
+	/* Close read end */
+	close(pipefd[0]);
+
+	errno = 0;
+	long ret = syscall(__NR_write, pipefd[1], "hello", 5);
+
+	if (ret == -1 && errno == EPIPE && got_sigpipe) {
+		tap_ok("write to closed pipe returns EPIPE + SIGPIPE");
+	} else if (ret == -1 && errno == EPIPE) {
+		tap_ok("write to closed pipe returns EPIPE (SIGPIPE not caught)");
+	} else {
+		char msg[128];
+
+		snprintf(msg, sizeof(msg),
+			 "expected EPIPE, got %s (sigpipe=%d)",
+			 ret >= 0 ? "success" : strerror(errno),
+			 (int)got_sigpipe);
+		tap_fail("write to closed pipe returns EPIPE + SIGPIPE", msg);
+	}
+
+	/* Restore SIGPIPE handler */
+	sigaction(SIGPIPE, &old_sa, NULL);
+	close(pipefd[1]);
+}
+
+/*
+ * Test 23: open with O_DIRECTORY on a non-directory returns ENOTDIR
+ */
+static void test_open_directory_on_file(void)
+{
+	errno = 0;
+	long ret = kapi_sys_open("/dev/null", O_RDONLY | O_DIRECTORY, 0);
+
+	if (ret == -1 && errno == ENOTDIR) {
+		tap_ok("open O_DIRECTORY on non-directory returns ENOTDIR");
+	} else if (ret >= 0) {
+		tap_fail("open O_DIRECTORY on non-directory",
+			 "expected ENOTDIR, got success");
+		syscall(__NR_close, (int)ret);
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected ENOTDIR, got %s",
+			 strerror(errno));
+		tap_fail("open O_DIRECTORY on non-directory", msg);
+	}
+}
+
+/*
+ * Test 24: open nonexistent file without O_CREAT returns ENOENT
+ */
+static void test_open_nonexistent(void)
+{
+	errno = 0;
+	long ret = kapi_sys_open("/tmp/kapi_nonexistent_file_12345",
+				 O_RDONLY, 0);
+
+	if (ret == -1 && errno == ENOENT) {
+		tap_ok("open nonexistent file without O_CREAT returns ENOENT");
+	} else if (ret >= 0) {
+		tap_fail("open nonexistent file",
+			 "expected ENOENT, got success (file exists?)");
+		syscall(__NR_close, (int)ret);
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected ENOENT, got %s",
+			 strerror(errno));
+		tap_fail("open nonexistent file", msg);
+	}
+}
+
+/*
+ * Test 25: close stdin (fd 0) should succeed
+ * We dup it first so we can restore it.
+ */
+static void test_close_stdin(void)
+{
+	int saved_stdin = dup(0);
+
+	if (saved_stdin < 0) {
+		tap_fail("close stdin succeeds", "cannot dup stdin");
+		return;
+	}
+
+	errno = 0;
+	long ret = syscall(__NR_close, 0);
+
+	if (ret == 0) {
+		tap_ok("close stdin (fd 0) succeeds");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected success, got %s",
+			 strerror(errno));
+		tap_fail("close stdin (fd 0) succeeds", msg);
+	}
+
+	/* Restore stdin */
+	dup2(saved_stdin, 0);
+	close(saved_stdin);
+}
+
+/*
+ * Test 26: read after close returns EBADF
+ */
+static void test_read_after_close(void)
+{
+	long fd;
+
+	errno = 0;
+	fd = kapi_sys_open("/dev/null", O_RDONLY, 0);
+	if (fd < 0) {
+		tap_fail("read after close returns EBADF",
+			 "cannot open /dev/null");
+		return;
+	}
+
+	syscall(__NR_close, (int)fd);
+
+	char buf[16];
+
+	errno = 0;
+	long ret = syscall(__NR_read, (int)fd, buf, sizeof(buf));
+
+	if (ret == -1 && errno == EBADF) {
+		tap_ok("read after close returns EBADF");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected EBADF, got %s",
+			 ret >= 0 ? "success" : strerror(errno));
+		tap_fail("read after close returns EBADF", msg);
+	}
+}
+
+/*
+ * Test 27: write with large count
+ * Without KAPI: the kernel clamps count to MAX_RW_COUNT and succeeds.
+ * With KAPI: KAPI validates the buffer against the count and may
+ * return EFAULT/EINVAL since the buffer is smaller than count.
+ * Accept either success or EFAULT/EINVAL.
+ */
+static void test_write_large_count(void)
+{
+	long fd;
+	char buf[64] = "test data";
+
+	errno = 0;
+	fd = kapi_sys_open("/dev/null", O_WRONLY, 0);
+	if (fd < 0) {
+		tap_fail("write with large count handled correctly",
+			 "cannot open /dev/null");
+		return;
+	}
+
+	errno = 0;
+	long ret = syscall(__NR_write, (int)fd, buf, (size_t)0x7ffff000UL);
+
+	if (ret > 0) {
+		tap_ok("write with large count succeeds (clamped, no KAPI)");
+	} else if (ret == -1 && (errno == EFAULT || errno == EINVAL)) {
+		tap_ok("write with large count returns EFAULT/EINVAL (KAPI validates buffer)");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected success or EFAULT, got %s",
+			 ret == 0 ? "zero" : strerror(errno));
+		tap_fail("write with large count handled correctly", msg);
+	}
+
+	syscall(__NR_close, (int)fd);
+}
+
+/* ---- Integration tests ---- */
+
+/*
+ * Test 28: full normal syscall path - open, read, write, close
+ * Verify KAPI does not interfere with normal operations.
+ */
+static void test_normal_path(void)
+{
+	long rd_fd, wr_fd;
+	char buf[128];
+	int ok = 1;
+	char reason[128] = "";
+
+	/* Open a readable file */
+	errno = 0;
+	rd_fd = kapi_sys_open("/etc/hostname", O_RDONLY, 0);
+	if (rd_fd < 0) {
+		errno = 0;
+		rd_fd = kapi_sys_open("/etc/passwd", O_RDONLY, 0);
+	}
+	if (rd_fd < 0) {
+		snprintf(reason, sizeof(reason), "open readable file: %s",
+			 strerror(errno));
+		ok = 0;
+	}
+
+	/* Read from it */
+	if (ok) {
+		errno = 0;
+		long n = syscall(__NR_read, (int)rd_fd, buf, sizeof(buf));
+
+		if (n < 0) {
+			snprintf(reason, sizeof(reason), "read: %s",
+				 strerror(errno));
+			ok = 0;
+		}
+	}
+
+	/* Open /dev/null for writing */
+	wr_fd = -1;
+	if (ok) {
+		errno = 0;
+		wr_fd = kapi_sys_open("/dev/null", O_WRONLY, 0);
+		if (wr_fd < 0) {
+			snprintf(reason, sizeof(reason),
+				 "open /dev/null: %s", strerror(errno));
+			ok = 0;
+		}
+	}
+
+	/* Write to /dev/null */
+	if (ok) {
+		errno = 0;
+		long n = syscall(__NR_write, (int)wr_fd, "test", 4);
+
+		if (n != 4) {
+			snprintf(reason, sizeof(reason), "write: %s",
+				 n < 0 ? strerror(errno) : "short write");
+			ok = 0;
+		}
+	}
+
+	/* Close both fds */
+	if (rd_fd >= 0) {
+		errno = 0;
+		if (syscall(__NR_close, (int)rd_fd) != 0 && ok) {
+			snprintf(reason, sizeof(reason), "close read fd: %s",
+				 strerror(errno));
+			ok = 0;
+		}
+	}
+
+	if (wr_fd >= 0) {
+		errno = 0;
+		if (syscall(__NR_close, (int)wr_fd) != 0 && ok) {
+			snprintf(reason, sizeof(reason), "close write fd: %s",
+				 strerror(errno));
+			ok = 0;
+		}
+	}
+
+	if (ok)
+		tap_ok("normal syscall path (open/read/write/close) works");
+	else
+		tap_fail("normal syscall path (open/read/write/close) works",
+			 reason);
+}
+
+/*
+ * Test 29: read with a NULL buffer and count=0 should return 0
+ * No access is performed, so KAPI must not reject the NULL pointer.
+ */
+static void test_read_null_zero_count(void)
+{
+	long fd;
+
+	errno = 0;
+	fd = kapi_sys_open("/dev/null", O_RDONLY, 0);
+	if (fd < 0) {
+		tap_fail("read with NULL buffer and count=0 returns 0",
+			 "cannot open /dev/null");
+		return;
+	}
+
+	errno = 0;
+	long ret = syscall(__NR_read, (int)fd, NULL, 0);
+
+	if (ret == 0) {
+		tap_ok("read with NULL buffer and count=0 returns 0");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected 0, got %ld (errno=%s)",
+			 ret, strerror(errno));
+		tap_fail("read with NULL buffer and count=0 returns 0", msg);
+	}
+
+	syscall(__NR_close, (int)fd);
+}
+
+/*
+ * Test 30: write with a NULL buffer and count=0 should return 0
+ * No access is performed, so KAPI must not reject the NULL pointer.
+ */
+static void test_write_null_zero_count(void)
+{
+	long fd;
+
+	errno = 0;
+	fd = kapi_sys_open("/dev/null", O_WRONLY, 0);
+	if (fd < 0) {
+		tap_fail("write with NULL buffer and count=0 returns 0",
+			 "cannot open /dev/null");
+		return;
+	}
+
+	errno = 0;
+	long ret = syscall(__NR_write, (int)fd, NULL, 0);
+
+	if (ret == 0) {
+		tap_ok("write with NULL buffer and count=0 returns 0");
+	} else {
+		char msg[64];
+
+		snprintf(msg, sizeof(msg), "expected 0, got %ld (errno=%s)",
+			 ret, strerror(errno));
+		tap_fail("write with NULL buffer and count=0 returns 0", msg);
+	}
+
+	syscall(__NR_close, (int)fd);
+}
+
+/*
+ * Test 31: verify dmesg contains KAPI warnings for the invalid tests
+ */
+static void test_dmesg_warnings(void)
+{
+	int kmsg_fd;
+
+	if (!kapi_runtime_checks_active()) {
+		tap_skip("dmesg contains expected KAPI warnings",
+			 "CONFIG_KAPI_RUNTIME_CHECKS not enabled");
+		return;
+	}
+
+	kmsg_fd = open("/dev/kmsg", O_RDONLY | O_NONBLOCK);
+	if (kmsg_fd < 0) {
+		tap_skip("dmesg contains expected KAPI warnings",
+			 "cannot open /dev/kmsg");
+		return;
+	}
+
+	char line[4096];
+	int found_invalid_bits = 0;
+	int found_null = 0;
+	ssize_t n;
+
+	for (;;) {
+		n = read(kmsg_fd, line, sizeof(line) - 1);
+		if (n > 0) {
+			line[n] = '\0';
+			if (strstr(line, "contains invalid bits"))
+				found_invalid_bits++;
+			if (strstr(line, "NULL") && strstr(line, "not allowed"))
+				found_null++;
+		} else if (n == -1 && errno == EPIPE) {
+			/* Ring buffer wrapped, continue reading */
+			continue;
+		} else {
+			/* EAGAIN (no more messages) or other error */
+			break;
+		}
+	}
+
+	close(kmsg_fd);
+
+	if (found_invalid_bits >= 2 && found_null >= 1) {
+		tap_ok("dmesg contains expected KAPI warnings");
+	} else {
+		char msg[128];
+
+		snprintf(msg, sizeof(msg),
+			 "expected invalid_bits>=2 null>=1, got invalid_bits=%d null=%d",
+			 found_invalid_bits, found_null);
+		tap_fail("dmesg contains expected KAPI warnings", msg);
+	}
+}
+
+int main(void)
+{
+	ksft_print_header();
+	ksft_set_plan(NUM_TESTS);
+
+	/* Valid operations (1-4) */
+	int fd = test_open_valid();
+
+	if (fd >= 0)
+		test_read_valid(fd);
+	else
+		tap_fail("read from valid fd", "no fd from open");
+
+	test_write_valid();
+
+	if (fd >= 0)
+		test_close_valid(fd);
+	else
+		tap_fail("close valid fd", "no fd from open");
+
+	/* KAPI parameter validation (5-8) */
+	test_open_invalid_flags();
+	test_open_high_mode_bits();
+	test_open_null_path();
+	test_open_flag_bit30();
+
+	/* Boundary conditions and error paths (9-20) */
+	test_read_bad_fd();
+	test_read_zero_count();
+	test_write_zero_count();
+	test_open_long_path();
+	test_read_unmapped_buf();
+	test_write_unmapped_buf();
+	test_close_already_closed();
+	test_open_valid_cloexec();
+	test_write_zero_devnull();
+	test_read_writeonly_fd();
+	test_write_readonly_fd();
+	test_close_fd_9999();
+
+	/* Pipe and lifecycle tests (21-27) */
+	test_read_closed_pipe();
+	test_write_closed_pipe();
+	test_open_directory_on_file();
+	test_open_nonexistent();
+	test_close_stdin();
+	test_read_after_close();
+	test_write_large_count();
+
+	/* Integration (28-31) */
+	test_normal_path();
+	test_read_null_zero_count();
+	test_write_null_zero_count();
+	test_dmesg_warnings();
+
+	ksft_finished();
+	return 0;
+}
-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* [PATCH v5 10/11] kernel/api: add API specification for sys_madvise
  2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
                   ` (8 preceding siblings ...)
  2026-10-08  8:49 ` [PATCH v5 09/11] kernel/api: add runtime verification selftest Sasha Levin
@ 2026-10-08  8:49 ` Sasha Levin
  2026-10-08  8:49 ` [PATCH v5 11/11] kernel/api: add syscall enter/exit tracepoints Sasha Levin
  10 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

Add KAPI-annotated kerneldoc for the sys_madvise system call in
mm/madvise.c.

The specification documents the parameters (start, len_in, behavior),
per-behavior error conditions, lock acquisition (mmap_lock read and
write modes plus the per-VMA fast path, mmu_gather and mmu_notifier
brackets), signal handling, side effects, capability requirements
(CAP_SYS_ADMIN for MADV_HWPOISON and MADV_SOFT_OFFLINE), mseal
interaction, and how each behavior skips pages or reports errors.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 mm/madvise.c | 549 +++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 549 insertions(+)

diff --git a/mm/madvise.c b/mm/madvise.c
index eeee82cf2b3f4..4eb29922a2150 100644
--- a/mm/madvise.c
+++ b/mm/madvise.c
@@ -2018,6 +2018,555 @@ int do_madvise(struct mm_struct *mm, unsigned long start, size_t len_in, int beh
 	return error;
 }
 
+/**
+ * sys_madvise - Give advice about use of memory
+ * @start: Starting virtual address of the range to advise on
+ * @len_in: Length of the range in bytes
+ * @behavior: Advice (a MADV_* constant) the kernel should apply to the range
+ *
+ * long-desc: Provides the kernel with advice or directions about the address
+ *   range starting at start and extending for len_in bytes. The advice is
+ *   selected by behavior, which is one of the MADV_* constants defined in
+ *   <sys/mman.h>. The behaviors fall into three groups. The hint group
+ *   updates VMA flags (MADV_NORMAL, MADV_RANDOM, MADV_SEQUENTIAL,
+ *   MADV_DONTFORK, MADV_DOFORK, MADV_DONTDUMP, MADV_DODUMP, MADV_WIPEONFORK,
+ *   MADV_KEEPONFORK, MADV_MERGEABLE, MADV_UNMERGEABLE, MADV_HUGEPAGE,
+ *   MADV_NOHUGEPAGE). The immediate-action group performs work synchronously
+ *   while preserving page contents (MADV_WILLNEED, MADV_COLD, MADV_PAGEOUT,
+ *   MADV_POPULATE_READ, MADV_POPULATE_WRITE, MADV_COLLAPSE, MADV_GUARD_REMOVE,
+ *   MADV_SOFT_OFFLINE). The destructive group discards, replaces or
+ *   invalidates page contents (MADV_DONTNEED, MADV_DONTNEED_LOCKED, MADV_FREE,
+ *   MADV_REMOVE, MADV_GUARD_INSTALL, MADV_HWPOISON). MADV_GUARD_INSTALL
+ *   belongs to the destructive group because it zaps any existing pages in
+ *   the range before installing PTE guard markers.
+ *
+ *   start must be page-aligned; len_in is rounded up to the next page
+ *   boundary internally. Once those validation checks pass, a zero-length
+ *   range succeeds without performing work. The kernel rejects ranges that
+ *   wrap (start + PAGE_ALIGN(len_in) < start) and ranges where len_in is
+ *   non-zero but rounds up to zero. Address tagging bits are stripped
+ *   from start before VMA lookup for every behavior except MADV_HWPOISON
+ *   and MADV_SOFT_OFFLINE, which receive the raw start value because they
+ *   bypass the VMA walk entirely.
+ *
+ *   The kernel return value reports whether any error condition was
+ *   encountered, not whether the requested work was performed. The
+ *   relationship between the return code and the work done varies by
+ *   handler:
+ *
+ *     - Hint behaviors set or clear VMA flags; how the flag is used later
+ *       depends on the behavior. MADV_DONTFORK / MADV_DOFORK set or clear
+ *       VM_DONTCOPY, which dup_mmap() honors, so the child does not get the
+ *       mapping. MADV_MERGEABLE / MADV_UNMERGEABLE control whether KSM scans
+ *       the VMA. MADV_NOHUGEPAGE blocks the fault-time, MADV_COLLAPSE and
+ *       khugepaged THP paths for the VMA. MADV_HUGEPAGE makes the VMA
+ *       eligible for THP when the transparent_hugepage mode is "madvise" and
+ *       increases defrag effort; it does not force allocation, which still
+ *       depends on the global mode, VMA suitability, defrag GFP policy, and
+ *       allocation or memcg-charge success. MADV_WIPEONFORK does not wipe
+ *       pages at fork time; the child VMA's pages are not copied, and the
+ *       child sees zero-filled pages when it first touches them.
+ *       MADV_KEEPONFORK clears VM_WIPEONFORK. MADV_DONTDUMP / MADV_DODUMP set
+ *       or clear VM_DONTDUMP, although always_dump_vma() still includes gate,
+ *       vm_ops-named or arch-named VMAs in a core dump. MADV_NORMAL /
+ *       MADV_RANDOM / MADV_SEQUENTIAL set or clear VM_RAND_READ / VM_SEQ_READ,
+ *       which only steer read-ahead.
+ *
+ *     - Walk-and-skip handlers (MADV_COLD, MADV_PAGEOUT, MADV_FREE,
+ *       MADV_GUARD_REMOVE) traverse the range and silently skip pages or
+ *       PMDs that fail per-page preconditions (absent, special, device,
+ *       shared, non-LRU, unsplittable, locked, etc.), returning 0 even
+ *       when most or all pages were skipped.
+ *
+ *     - Bulk-backend handlers delegate the requested range to a single
+ *       backend call: MADV_DONTNEED and MADV_DONTNEED_LOCKED to
+ *       zap_vma_range_batched(), MADV_REMOVE to vfs_fallocate(),
+ *       MADV_WILLNEED on regular files to vfs_fadvise(). The backend's
+ *       return is propagated for MADV_REMOVE and discarded for
+ *       MADV_WILLNEED; DAX files short-circuit MADV_WILLNEED entirely.
+ *
+ *     - Stop-on-error handlers (MADV_POPULATE_READ, MADV_POPULATE_WRITE,
+ *       MADV_SOFT_OFFLINE) walk the range but surface the first per-page
+ *       failure as an errno (-EHWPOISON, -EFAULT, -ENOMEM, ...) rather
+ *       than skipping silently.
+ *
+ *     - Hybrid handlers combine modes: MADV_WILLNEED walks for anonymous
+ *       and shmem ranges but bulk-calls vfs_fadvise() for regular files;
+ *       MADV_COLLAPSE walks PMD-by-PMD and tracks the last scan failure
+ *       so transient skips coexist with terminal errors;
+ *       MADV_GUARD_INSTALL walks to install markers and re-walks after
+ *       zap_vma_range() to clear pre-existing pages, retrying up to
+ *       MAX_MADVISE_GUARD_RETRIES; MADV_HWPOISON walks pages but folds
+ *       memory_failure()'s -EOPNOTSUPP back to 0.
+ *
+ *   Applications that need to know whether a specific page was acted on
+ *   must verify the result through other means (e.g. /proc/[pid]/smaps,
+ *   page faults, read-after-write).
+ *
+ *   On success, madvise() returns 0; unlike read(2) and write(2) it has no
+ *   notion of partial completion at the syscall boundary. When the range
+ *   spans multiple VMAs, the kernel applies the advice to each in turn. For
+ *   the behaviors that walk VMAs (all except MADV_POPULATE_*, MADV_HWPOISON
+ *   and MADV_SOFT_OFFLINE), an unmapped gap inside the range causes the call
+ *   to return -ENOMEM after processing the mapped portions, rather than
+ *   aborting at the gap, although the walk stops at the first per-VMA error.
+ *   MADV_POPULATE_* stops at the first failure, and MADV_HWPOISON and
+ *   MADV_SOFT_OFFLINE bypass the walk, so an unmapped address gives -EFAULT.
+ *
+ *   POSIX defines posix_madvise(3) for a portable subset (POSIX_MADV_NORMAL,
+ *   _RANDOM, _SEQUENTIAL, _WILLNEED, _DONTNEED). Linux MADV_DONTNEED is
+ *   destructive: it discards the contents of the affected anonymous pages and
+ *   subsequent reads return zero. POSIX permits but does not require
+ *   destruction, so portable code that needs the POSIX semantics should use
+ *   posix_madvise(3) instead.
+ *
+ * contexts: process, sleepable
+ *
+ * param: start
+ *   type: uint, input
+ *   constraint-type: page_aligned
+ *   cdesc: Starting virtual address of the range. Must be aligned to
+ *     PAGE_SIZE. An unaligned start always returns -EINVAL, even when
+ *     len_in is zero. Address tag bits, where supported by the architecture,
+ *     are cleared via untagged_addr() before the range is interpreted, with
+ *     the exception of MADV_HWPOISON and MADV_SOFT_OFFLINE, which receive
+ *     the raw start value because they bypass the VMA walk.
+ *
+ * param: len_in
+ *   type: uint, input
+ *   cdesc: Length of the range in bytes. Internally rounded up to a multiple
+ *     of PAGE_SIZE. A len_in of 0 is accepted and the call is a no-op that
+ *     returns 0. A non-zero len_in that rounds up to 0 (i.e. wraps around)
+ *     returns -EINVAL, as does a range whose end (start + PAGE_ALIGN(len_in))
+ *     would wrap below start.
+ *
+ * param: behavior
+ *   type: int, input
+ *   cdesc: One of the MADV_* constants from <sys/mman.h>. See the long
+ *     description above for the full list and the three semantic groups
+ *     (hint, immediate-action, destructive). Behaviors gated by Kconfig
+ *     (KSM, transparent hugepage, memory failure) return -EINVAL when the
+ *     underlying support is disabled. A few architectures (notably alpha)
+ *     renumber values; portable code should always use the symbolic names.
+ *
+ * return:
+ *   type: int
+ *   check-type: exact
+ *   success: 0
+ *   desc: On success, returns 0. On error, returns a negative error code.
+ *     There is no partial-success indication; either the entire processed
+ *     range succeeded, or an error is returned and an unspecified prefix of
+ *     the range may have been advised.
+ *
+ * error: EINVAL, Invalid argument
+ *   desc: Invalid input (unknown or Kconfig-disabled MADV_*, unaligned start,
+ *     range wrap, non-zero len_in rounding up to zero) or a VMA filter.
+ *     DONTNEED/FREE reject VM_PFNMAP, VM_LOCKED (not DONTNEED_LOCKED) and
+ *     misaligned hugetlb; FREE non-anonymous; WIPEONFORK file or shared; REMOVE
+ *     VM_LOCKED or no file; COLD/PAGEOUT LOCKED/PFNMAP/HUGETLB; DOFORK
+ *     VM_SPECIAL; KEEPONFORK VM_DROPPABLE; DODUMP SPECIAL/DROPPABLE; GUARD_*
+ *     SPECIAL/HUGETLB (INSTALL also LOCKED); COLLAPSE if not possible;
+ *     POPULATE_* on bad permissions.
+ *
+ * error: ENOMEM, Cannot allocate memory
+ *   desc: For VMA-walking behaviors, a gap between mapped VMAs inside the range
+ *     gives -ENOMEM after the mapped subranges have been processed, unless a
+ *     per-VMA error ends the walk first. MADV_POPULATE_* stops at the first
+ *     failure and returns -ENOMEM when the region has no VMA or
+ *     faultin_page_range() exhausts memory. MADV_COLLAPSE returns -ENOMEM when
+ *     its struct collapse_control cannot be allocated, and when
+ *     madvise_collapse_errno() maps SCAN_ALLOC_HUGE_PAGE_FAIL (no hugepage
+ *     available) to it.
+ *
+ * error: EAGAIN, Resource temporarily unavailable
+ *   desc: For the VMA-flag-mutating behaviors, an internal -ENOMEM from VMA
+ *     splitting is translated to -EAGAIN before being returned to userspace,
+ *     advising the caller that a transient kernel resource shortage
+ *     prevented the update. Also returned by MADV_COLLAPSE via
+ *     madvise_collapse_errno() for transient scan failures (folio lock
+ *     contention, LRU isolation failure, dirty/writeback) where retrying
+ *     the call may succeed.
+ *
+ * error: EIO, Input/output error
+ *   desc: For MADV_REMOVE, an I/O error from the underlying filesystem's
+ *     FALLOC_FL_PUNCH_HOLE handler is propagated back as -EIO, and
+ *     MADV_SOFT_OFFLINE returns -EIO when soft_offline_page() cannot handle
+ *     the page. MADV_HWPOISON reports an unhandled page as -EBUSY; -EIO can
+ *     reach it only from a ZONE_DEVICE pagemap's ->memory_failure()
+ *     callback. MADV_WILLNEED and MADV_PAGEOUT do not surface filesystem or
+ *     device I/O errors: vfs_fadvise() returns are discarded by
+ *     madvise_willneed() and the pageout walk is invoked through a void
+ *     helper, so transient I/O failures during read-ahead or page-out are
+ *     silently dropped.
+ *
+ * error: EBADF, Bad file descriptor
+ *   desc: Returned by MADV_WILLNEED when applied to a non-file-backed VMA
+ *     and the kernel was built without CONFIG_SWAP, so there is neither a
+ *     file to read-ahead from nor a swap device to fault from.
+ *
+ * error: EACCES, Permission denied
+ *   desc: Returned by MADV_REMOVE when the target VMA fails
+ *     vma_is_shared_maywrite(), which needs both VM_SHARED and VM_MAYWRITE.
+ *     Private file mappings and shared mappings of files not opened for
+ *     writing are refused, while a PROT_READ MAP_SHARED mapping of a file
+ *     opened O_RDWR is accepted. Punching a hole through a refused mapping
+ *     would either be invisible to other mappers or bypass file write
+ *     permission.
+ *
+ * error: EPERM, Operation not permitted
+ *   desc: Returned in two situations. First, MADV_HWPOISON and
+ *     MADV_SOFT_OFFLINE require CAP_SYS_ADMIN; the inject-error handler
+ *     refuses non-privileged callers. Second, on 64-bit kernels, a discard
+ *     operation (MADV_FREE, MADV_DONTNEED, MADV_DONTNEED_LOCKED, MADV_REMOVE,
+ *     MADV_DONTFORK, MADV_WIPEONFORK, MADV_GUARD_INSTALL) is refused on a
+ *     read-only anonymous VMA that has been sealed with mseal(2), to prevent
+ *     bypassing the seal by discarding mapped data.
+ *
+ * error: EINTR, Interrupted system call
+ *   desc: Returned when a fatal signal is delivered while the call is
+ *     waiting to acquire the mmap write lock for a VMA-flag-mutating
+ *     behavior (mmap_write_lock_killable() returns -EINTR), or when
+ *     MADV_POPULATE_READ/MADV_POPULATE_WRITE is interrupted while faulting
+ *     in pages (faultin_page_range() returns -EINTR). Only a fatal signal
+ *     interrupts these waits, so the task is being killed and user space
+ *     does not normally see the error.
+ *
+ * error: EHWPOISON, Memory page has hardware error
+ *   desc: MADV_POPULATE_READ or MADV_POPULATE_WRITE encountered a page that
+ *     has been marked as containing a hardware-detected memory error and
+ *     could not be faulted in. MADV_HWPOISON also returns it when
+ *     memory_failure() finds the page already poisoned.
+ *
+ * error: EFAULT, Bad address
+ *   desc: MADV_POPULATE_READ or MADV_POPULATE_WRITE attempted to fault in a
+ *     page whose mapping raised VM_FAULT_SIGBUS or VM_FAULT_SIGSEGV (for
+ *     example, a file-backed page beyond the end of the file).
+ *     MADV_HWPOISON and MADV_SOFT_OFFLINE return a get_user_pages_fast()
+ *     failure, typically -EFAULT for an unmapped address.
+ *
+ * error: EBUSY, Device or resource busy
+ *   desc: Returned by MADV_COLLAPSE via madvise_collapse_errno() in two
+ *     specific scan-failure modes: SCAN_CGROUP_CHARGE_FAIL (the new
+ *     hugepage cannot be charged to the memory cgroup) and
+ *     SCAN_EXCEED_NONE_PTE (too many absent PTEs in the candidate range
+ *     for a synchronous collapse). Other transient collapse failures are
+ *     reported as -EAGAIN; non-transient ones as -EINVAL. MADV_HWPOISON and
+ *     MADV_SOFT_OFFLINE also return -EBUSY when the page cannot be handled.
+ *
+ * error: EOPNOTSUPP, Operation not supported
+ *   desc: MADV_REMOVE propagates vfs_fallocate() errors verbatim, so a
+ *     filesystem without FALLOC_FL_PUNCH_HOLE support fails with -EOPNOTSUPP
+ *     (other propagated errors include -EPERM, -ETXTBSY and -ENOSPC).
+ *     MADV_SOFT_OFFLINE also returns it when soft offlining is disabled via
+ *     /proc/sys/vm/enable_soft_offline or the event is filtered by
+ *     hwpoison_filter(); MADV_HWPOISON folds that case to 0.
+ *
+ * lock: mm->mmap_lock (read mode)
+ *   type: semaphore
+ *   acquired: yes
+ *   released: yes
+ *   desc: Taken for read for MADV_REMOVE, MADV_WILLNEED, MADV_COLD,
+ *     MADV_PAGEOUT, MADV_COLLAPSE, MADV_POPULATE_READ and
+ *     MADV_POPULATE_WRITE, and as the fallback when the per-VMA lock path
+ *     declines. It is dropped and retaken around vfs_fadvise() (WILLNEED on
+ *     regular files), vfs_fallocate() and userfaultfd_remove() (REMOVE, and
+ *     DONTNEED or FREE with UFFD_FEATURE_EVENT_REMOVE), the anon and file
+ *     collapse paths (COLLAPSE) and inside faultin_page_range()
+ *     (POPULATE_*), so the VMA must be looked up again afterwards.
+ *
+ * lock: mm->mmap_lock (write mode; killable)
+ *   type: semaphore
+ *   acquired: yes
+ *   released: yes
+ *   desc: Acquired in killable write mode for behaviors that modify
+ *     vma->vm_flags or split/merge VMAs (MADV_NORMAL, MADV_RANDOM,
+ *     MADV_SEQUENTIAL, MADV_DONTFORK, MADV_DOFORK, MADV_DONTDUMP, MADV_DODUMP,
+ *     MADV_WIPEONFORK, MADV_KEEPONFORK, MADV_MERGEABLE, MADV_UNMERGEABLE,
+ *     MADV_HUGEPAGE, MADV_NOHUGEPAGE). If the acquisition is killed by a
+ *     fatal signal, the syscall returns -EINTR before any VMA is touched.
+ *
+ * lock: per-VMA read lock (vma->vm_refcnt)
+ *   type: custom
+ *   acquired: yes
+ *   released: yes
+ *   desc: Tried first for MADV_DONTNEED, MADV_DONTNEED_LOCKED, MADV_FREE,
+ *     MADV_GUARD_INSTALL and MADV_GUARD_REMOVE via lock_vma_under_rcu(). The
+ *     lock is a reference on the VMA (vm_refcnt), not an rwsem. The per-VMA
+ *     path is taken only when the requested range fits within a single VMA,
+ *     the target mm is the caller's mm, the VMA is not armed with
+ *     userfaultfd, and, for MADV_GUARD_INSTALL on an anonymous VMA, an
+ *     anon_vma is already attached. Otherwise the code falls back to the
+ *     mmap read lock above.
+ *
+ * lock: mmu_gather TLB batch
+ *   type: custom
+ *   acquired: yes
+ *   released: yes
+ *   desc: For MADV_DONTNEED, MADV_DONTNEED_LOCKED and MADV_FREE a single
+ *     tlb_gather_mmu() / tlb_finish_mmu() pair (madvise_init_tlb() and
+ *     madvise_finish_tlb()) wraps the whole syscall, batching TLB invalidation
+ *     across all VMAs in the range. MADV_COLD and MADV_PAGEOUT build a
+ *     short-lived gather inside the handler. MADV_GUARD_INSTALL builds a
+ *     transient gather via zap_vma_range() each time the retry loop clears
+ *     pre-existing pages, and none if the range is already empty.
+ *     MADV_GUARD_REMOVE never gathers.
+ *
+ * lock: mmu_notifier invalidate range
+ *   type: custom
+ *   acquired: yes
+ *   released: yes
+ *   desc: All zap-based paths -- MADV_DONTNEED, MADV_DONTNEED_LOCKED, the
+ *     zap branch of MADV_GUARD_INSTALL via zap_vma_range(), and
+ *     MADV_FREE's own walk -- bracket their work with
+ *     mmu_notifier_invalidate_range_start()/_end() so secondary MMUs (KVM,
+ *     IOMMU SVA, etc.) observe the page clearing.
+ *
+ * signal: Any fatal signal
+ *   direction: receive
+ *   action: return
+ *   condition: Acquiring the mmap write lock or faulting in pages for
+ *     MADV_POPULATE_*
+ *   desc: A pending fatal signal aborts mmap_write_lock_killable() (used by
+ *     the VMA-flag-mutating behaviors) and faultin_page_range() (used by
+ *     MADV_POPULATE_READ and MADV_POPULATE_WRITE), in both cases surfacing as
+ *     -EINTR. Only a fatal signal interrupts these waits, so the task is
+ *     being killed and user space does not normally see the error.
+ *   errno: -EINTR
+ *   timing: during
+ *   restartable: no
+ *
+ * side-effect: modify_state
+ *   target: vma->vm_flags
+ *   condition: Hint-group behaviors (MADV_NORMAL, MADV_RANDOM, MADV_SEQUENTIAL,
+ *     MADV_DONTFORK, MADV_DOFORK, MADV_DONTDUMP, MADV_DODUMP, MADV_WIPEONFORK,
+ *     MADV_KEEPONFORK, MADV_MERGEABLE, MADV_UNMERGEABLE, MADV_HUGEPAGE,
+ *     MADV_NOHUGEPAGE)
+ *   desc: Sets or clears VM_RAND_READ, VM_SEQ_READ, VM_DONTCOPY,
+ *     VM_DONTDUMP, VM_WIPEONFORK, VM_MERGEABLE, VM_HUGEPAGE or VM_NOHUGEPAGE
+ *     on the affected VMAs, splitting or merging VMAs as needed. Reversible
+ *     with the inverse advice (MADV_DOFORK undoes MADV_DONTFORK), except
+ *     that the inverse call's VMA filter still applies (DOFORK rejects
+ *     VM_SPECIAL, DODUMP rejects non-hugetlb VM_SPECIAL or VM_DROPPABLE,
+ *     KEEPONFORK rejects VM_DROPPABLE).
+ *   reversible: yes
+ *
+ * side-effect: free_memory | modify_state | irreversible
+ *   target: page tables and resident pages within the range
+ *   condition: MADV_DONTNEED, MADV_DONTNEED_LOCKED, MADV_FREE
+ *   desc: MADV_DONTNEED zaps PTEs, releasing the pages or swap slots so the
+ *     next access faults in zero-filled anonymous pages or re-reads the
+ *     file. MADV_DONTNEED_LOCKED is identical but tolerates VM_LOCKED.
+ *     MADV_FREE marks anonymous pages lazy-freeable; clean pages may be
+ *     reclaimed under memory pressure and a write before reclaim cancels
+ *     the lazy free. Discarded data cannot be recovered.
+ *   reversible: no
+ *
+ * side-effect: filesystem | irreversible
+ *   target: backing file (FALLOC_FL_PUNCH_HOLE)
+ *   condition: MADV_REMOVE
+ *   desc: Calls vfs_fallocate(FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE) on
+ *     the backing file, deallocating the corresponding file blocks. The hole
+ *     is visible to all mappers of the file and to read(2)/write(2)
+ *     callers; subsequent reads return zero. Filesystem freeze protection,
+ *     i_rwsem and any quota/space accounting are taken by the underlying
+ *     fallocate path.
+ *   reversible: no
+ *
+ * side-effect: modify_state | schedule
+ *   target: LRU lists and page reclaim
+ *   condition: MADV_COLD, MADV_PAGEOUT
+ *   desc: MADV_COLD deactivates the affected pages, moving them to the
+ *     inactive LRU and clearing PG_referenced/PG_young so they are reclaimed
+ *     sooner under pressure. MADV_PAGEOUT additionally calls reclaim_pages()
+ *     to write dirty pages out and drop clean ones synchronously. Page data
+ *     is preserved (rereads will fault in the same content), but the I/O and
+ *     LRU bookkeeping cannot be undone.
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: page tables (faultin)
+ *   condition: MADV_POPULATE_READ, MADV_POPULATE_WRITE
+ *   desc: Walks the requested range with faultin_page_range(), populating
+ *     PTEs by triggering read or write faults so subsequent accesses do not
+ *     fault. Equivalent to touching every page in the range while suppressing
+ *     SIGBUS/SIGSEGV through the syscall return value. Allocations made by
+ *     faultin are not undone on partial failure.
+ *   reversible: no
+ *
+ * side-effect: modify_state | schedule
+ *   target: transparent hugepage layout
+ *   condition: MADV_COLLAPSE
+ *   desc: Synchronously coalesces base pages in the range into a PMD-sized
+ *     transparent hugepage when the mapping permits. Performs the same page
+ *     migration and zeroing that khugepaged would do asynchronously; the
+ *     range's data is preserved across the collapse.
+ *   reversible: no
+ *
+ * side-effect: free_memory | modify_state | irreversible
+ *   target: PTE marker (PTE_MARKER_GUARD)
+ *   condition: MADV_GUARD_INSTALL, MADV_GUARD_REMOVE
+ *   desc: MADV_GUARD_INSTALL installs PTE_MARKER_GUARD entries that cause
+ *     subsequent accesses to deliver SIGSEGV without consuming physical
+ *     memory; existing pages already mapped in the range are zapped via
+ *     zap_vma_range() before the markers are installed, so any
+ *     prior contents are lost. MADV_GUARD_REMOVE clears the markers but
+ *     does not (and cannot) restore zapped data.
+ *   reversible: no
+ *
+ * side-effect: hardware | irreversible
+ *   target: physical page (memory_failure)
+ *   condition: MADV_HWPOISON
+ *   desc: MADV_HWPOISON marks the affected pages as containing an
+ *     unrecoverable hardware error using the same machine-check path that
+ *     real ECC failures take. This affects physical memory bookkeeping
+ *     kernel-wide, and madvise() has no inverse. The poison can be cleared
+ *     through unpoison_memory() (the hwpoison-inject debugfs interface).
+ *     Intended for testing the memory-failure pipeline; restricted to
+ *     CAP_SYS_ADMIN.
+ *   reversible: no
+ *
+ * side-effect: hardware
+ *   target: physical page (soft_offline_page)
+ *   condition: MADV_SOFT_OFFLINE
+ *   desc: Migrates the contents off the affected pages and removes them from
+ *     the buddy allocator. Page contents are preserved. madvise() has no
+ *     inverse. The pages can be brought back through unpoison_memory() (the
+ *     hwpoison-inject debugfs interface). Intended for testing the
+ *     memory-failure pipeline; restricted to CAP_SYS_ADMIN.
+ *   reversible: no
+ *
+ * side-effect: modify_state
+ *   target: KSM merge state (vm_flags & VM_MERGEABLE)
+ *   condition: MADV_MERGEABLE, MADV_UNMERGEABLE
+ *   desc: Toggles VM_MERGEABLE, the VMA's eligibility for the kernel
+ *     same-page merger. MADV_MERGEABLE lets ksmd later replace identical
+ *     anonymous pages with shared, write-protected copies and is silently
+ *     ignored on KSM-incompatible VMAs. MADV_UNMERGEABLE synchronously
+ *     calls break_ksm(), which faults each KSM page in the range back to an
+ *     exclusive copy, before clearing VM_MERGEABLE. An -ENOMEM from either
+ *     advice is returned as -EAGAIN. The flag toggle is reversible by
+ *     issuing the inverse advice.
+ *   reversible: yes
+ *
+ * side-effect: modify_state
+ *   target: userfaultfd event queue
+ *   condition: MADV_DONTNEED, MADV_DONTNEED_LOCKED, MADV_FREE, MADV_REMOVE
+ *     on a VMA whose userfaultfd context negotiated UFFD_FEATURE_EVENT_REMOVE
+ *   desc: Generates a UFFD_EVENT_REMOVE notification covering the discarded
+ *     range so userfaultfd monitors observing the mapping see the
+ *     invalidation. The event is queued before the discard takes effect; the
+ *     monitor cannot veto it.
+ *   reversible: no
+ *
+ * capability: CAP_SYS_ADMIN
+ *   type: perform_operation
+ *   allows: Inject memory errors via MADV_HWPOISON or MADV_SOFT_OFFLINE
+ *   without: Both behaviors return -EPERM
+ *   condition: Checked at entry to madvise_inject_error() before any pages
+ *     are looked up
+ *
+ * constraint: Page-aligned start
+ *   desc: start must lie on a page boundary; otherwise the call returns
+ *     -EINVAL before any VMA is consulted.
+ *   expr: (start & (PAGE_SIZE - 1)) == 0
+ *
+ * constraint: Length rounded up to PAGE_SIZE
+ *   desc: The effective range length is PAGE_ALIGN(len_in). A non-zero len_in
+ *     that overflows during rounding, or a (start, end) range that wraps,
+ *     is rejected with -EINVAL.
+ *   expr: end = start + PAGE_ALIGN(len_in); end >= start
+ *
+ * constraint: Behavior must be supported
+ *   desc: behavior must be one of the MADV_* values listed under the
+ *     behavior parameter. Behaviors gated by Kconfig (KSM, THP, memory
+ *     failure) are rejected with -EINVAL when the corresponding option is
+ *     disabled in the running kernel.
+ *
+ * constraint: mseal-protected discards
+ *   desc: On 64-bit kernels, a discard operation (FREE, DONTNEED,
+ *     DONTNEED_LOCKED, REMOVE, DONTFORK, WIPEONFORK, GUARD_INSTALL) against
+ *     a sealed anonymous VMA is rejected unless the mapping is currently
+ *     writable -- both VM_WRITE in vm_flags and arch_vma_access_permitted()
+ *     allowing write -- so that mseal(2) cannot be bypassed by instructing
+ *     the kernel to throw the data away. File-backed sealed VMAs and
+ *     writable sealed VMAs are not subject to this restriction.
+ *   expr: !is_discard(behavior) || !vma_is_sealed(vma) ||
+ *     !vma_is_anonymous(vma) || ((vma->vm_flags & VM_WRITE) &&
+ *     arch_vma_access_permitted(vma, true, false, false))
+ *
+ * constraint: MADV_FREE requires anonymous mappings
+ *   desc: MADV_FREE is defined only over anonymous mappings; the handler
+ *     requires vma_is_anonymous() (no vm_ops) and rejects file-backed VMAs,
+ *     including shared anonymous (shmem) VMAs, with -EINVAL.
+ *   expr: vma_is_anonymous(vma)
+ *
+ * constraint: MADV_WIPEONFORK requires private anonymous mappings
+ *   desc: MADV_WIPEONFORK rejects file-backed mappings and shared anonymous
+ *     mappings; only MAP_PRIVATE anonymous VMAs accept it. Both rejections
+ *     surface as -EINVAL.
+ *   expr: !vma->vm_file && !(vma->vm_flags & VM_SHARED)
+ *
+ * constraint: MADV_REMOVE requires a shared file mapping that may be written
+ *   desc: MADV_REMOVE rejects VM_LOCKED VMAs and VMAs without an associated
+ *     file/mapping/host inode with -EINVAL. It rejects VMAs failing
+ *     vma_is_shared_maywrite() (VM_SHARED and VM_MAYWRITE) with -EACCES,
+ *     which covers private file mappings and shared mappings of files not
+ *     opened for writing, but not a PROT_READ MAP_SHARED mapping of a file
+ *     opened O_RDWR.
+ *   expr: !(vma->vm_flags & VM_LOCKED) && vma->vm_file &&
+ *     vma->vm_file->f_mapping && vma->vm_file->f_mapping->host &&
+ *     vma_is_shared_maywrite(vma)
+ *
+ * constraint: MADV_COLD / MADV_PAGEOUT VMA filter
+ *   desc: Both behaviors require LRU-managed pages; they reject VMAs that
+ *     are mlocked, raw-PFN or hugetlb.
+ *   expr: !(vma->vm_flags & (VM_LOCKED | VM_PFNMAP | VM_HUGETLB))
+ *
+ * examples: madvise(p, len, MADV_SEQUENTIAL);  // set VM_SEQ_READ on the VMA
+ *   madvise(p, len, MADV_POPULATE_WRITE);  // prefault writable PTEs
+ *   madvise(p, len, MADV_DONTNEED);        // discard anonymous pages
+ *   madvise(p, len, MADV_GUARD_INSTALL);   // install SIGSEGV guard pages
+ *
+ * notes: Behavior introduction history (mainline): MADV_FREE in 4.5,
+ *   MADV_WIPEONFORK / MADV_KEEPONFORK in 4.14, MADV_COLD / MADV_PAGEOUT in
+ *   5.4, MADV_POPULATE_READ / MADV_POPULATE_WRITE in 5.14,
+ *   MADV_DONTNEED_LOCKED in 5.18, MADV_COLLAPSE in 6.1, MADV_GUARD_INSTALL /
+ *   MADV_GUARD_REMOVE in 6.13. Code that wants to remain portable to older
+ *   kernels must handle -EINVAL gracefully and fall back.
+ *
+ *   process_madvise(2) applies the same advice values to another process
+ *   identified by a pidfd. When the target mm is the caller's own (the
+ *   pidfd refers to the caller), any locally-supported MADV_* value is
+ *   accepted. When the target is a different mm, the behavior must be in
+ *   the non-destructive remote subset (MADV_COLD, MADV_PAGEOUT,
+ *   MADV_WILLNEED, MADV_COLLAPSE) or the call returns -EINVAL, and the
+ *   caller must hold CAP_SYS_NICE.
+ *
+ *   MADV_PAGEOUT on a non-anonymous VM_MAYSHARE mapping is a silent no-op
+ *   returning 0 unless can_do_file_pageout() holds, meaning the caller owns
+ *   the file or is capable over it (file_owner_or_capable(), which honors
+ *   the mount idmap) or may write it (file_permission(MAY_WRITE)). On a
+ *   private file mapping that fails the same test, only anonymous pages are
+ *   paged out. MADV_COLD has no such filter.
+ *
+ *   MADV_GUARD_INSTALL retries up to MAX_MADVISE_GUARD_RETRIES (3) times
+ *   when it loses races with concurrent faulting or khugepaged. If those
+ *   retries are exhausted the handler returns -ERESTARTNOINTR via
+ *   restart_syscall(), which sets TIF_SIGPENDING so the return path runs
+ *   signal handling. madvise() is then transparently re-executed with the
+ *   same arguments, after any handler for a pending signal has run, unless
+ *   a fatal signal terminates the task first. The caller never observes an
+ *   errno from the restart itself and the call appears to make eventual
+ *   forward progress.
+ *
+ *   anon_vma_prepare() failures inside MADV_GUARD_INSTALL bypass the
+ *   ENOMEM-to-EAGAIN translation that applies to the VMA-flag-mutating
+ *   behaviors and surface as -ENOMEM directly.
+ *
+ *   Architecture note: alpha defines MADV_DONTNEED as 6 (not 4) and reserves
+ *   MADV_SPACEAVAIL=5; portable code must use the symbolic names from
+ *   <sys/mman.h>.
+ */
 SYSCALL_DEFINE3(madvise, unsigned long, start, size_t, len_in, int, behavior)
 {
 	return do_madvise(current->mm, start, len_in, behavior);
-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* [PATCH v5 11/11] kernel/api: add syscall enter/exit tracepoints
  2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
                   ` (9 preceding siblings ...)
  2026-10-08  8:49 ` [PATCH v5 10/11] kernel/api: add API specification for sys_madvise Sasha Levin
@ 2026-10-08  8:49 ` Sasha Levin
  10 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08  8:49 UTC (permalink / raw)
  To: linux-api, linux-kernel
  Cc: Sasha Levin, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

Add two tracepoints to the CONFIG_KAPI_RUNTIME_CHECKS syscall validation
path so the framework's behavior can be observed without the noise and
loss of pr_warn_ratelimited():

  kapi_syscall_enter - the spec name, the raw argument values, and a
                       rendered "name=value" list of the specified
                       parameters (pointer-like values in hex, signed
                       integers and file descriptors in decimal)
  kapi_syscall_exit  - the spec name, the return value, and whether it
                       matched the specification (spec_match)

Both fire only for syscalls that have a KAPI specification and live
inside the existing CONFIG_KAPI_RUNTIME_CHECKS region, so they exist
exactly when the runtime checks do; they compile to no-ops without
CONFIG_TRACEPOINTS and stay dormant until enabled. The parameter list
is rendered only when the enter tracepoint is enabled.

kapi_syscall_exit is also emitted on the parameter-validation rejection
path -- where the validator returns -EINVAL and the real handler is
skipped -- with spec_match=0, so every kapi_syscall_enter has a matching
exit.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 Documentation/dev-tools/kernel-api-spec.rst | 31 ++++++++
 MAINTAINERS                                 |  1 +
 include/trace/events/kapi.h                 | 74 +++++++++++++++++++
 kernel/api/kernel_api_spec.c                | 79 +++++++++++++++++----
 4 files changed, 173 insertions(+), 12 deletions(-)
 create mode 100644 include/trace/events/kapi.h

diff --git a/Documentation/dev-tools/kernel-api-spec.rst b/Documentation/dev-tools/kernel-api-spec.rst
index 95b72060f8ef8..818c9b966a0c0 100644
--- a/Documentation/dev-tools/kernel-api-spec.rst
+++ b/Documentation/dev-tools/kernel-api-spec.rst
@@ -312,6 +312,37 @@ specification. Kerneldoc annotations cannot set it, so it is only available to a
     .constraint_type = KAPI_CONSTRAINT_CUSTOM,
     .validate = validate_buffer_size,
 
+Tracepoints
+-----------
+
+When ``CONFIG_KAPI_RUNTIME_CHECKS`` is enabled, the syscall validation path emits
+two ftrace tracepoints (in the ``kapi`` trace system) for every syscall that has a
+specification:
+
+- ``kapi_syscall_enter`` -- fired before parameter validation, recording the spec
+  name, the raw syscall argument values, and -- when the spec provides parameter
+  metadata -- a rendered ``name=value`` list: signed integer and file descriptor
+  values are shown in decimal, all other values in hex, and an unnamed parameter
+  as ``arg``.
+- ``kapi_syscall_exit`` -- fired after the handler returns, or in place of the
+  handler when parameter validation rejects the call (the handler is skipped and
+  ``-EINVAL`` is returned). Records the spec name, the return value, and
+  ``spec_match``: 0 when the parameters were rejected or a successful ``fd`` return
+  was not a valid file descriptor, and 1 otherwise. Error codes missing from the
+  spec do not clear it.
+
+Unlike the ``pr_warn_ratelimited`` violation reports, the tracepoints capture every
+spec'd call rather than only violations, and can be filtered with the usual ftrace
+facilities. They require ``CONFIG_TRACEPOINTS`` and stay dormant
+until enabled::
+
+    # echo 1 > /sys/kernel/tracing/events/kapi/enable
+    # cat /sys/kernel/tracing/trace
+     ...  kapi_syscall_enter: sys_read(fd=3, buf=0x7ffd46780b58, count=0x340)
+     ...  kapi_syscall_exit: sys_read = 832 spec_match=1
+     ...  kapi_syscall_enter: sys_open(filename=0x480300, flags=268435456, mode=0x0)
+     ...  kapi_syscall_exit: sys_open = -22 spec_match=0
+
 DebugFS Interface
 =================
 
diff --git a/MAINTAINERS b/MAINTAINERS
index 1d9e55b6bf5ff..20020705921a6 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -14125,6 +14125,7 @@ S:	Maintained
 F:	Documentation/dev-tools/kernel-api-spec.rst
 F:	include/linux/kapi_syscall.h
 F:	include/linux/kernel_api_spec.h
+F:	include/trace/events/kapi.h
 F:	kernel/api/
 F:	tools/kapi/
 F:	tools/lib/python/kdoc/kdoc_apispec.py
diff --git a/include/trace/events/kapi.h b/include/trace/events/kapi.h
new file mode 100644
index 0000000000000..47828f3338828
--- /dev/null
+++ b/include/trace/events/kapi.h
@@ -0,0 +1,74 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+#undef TRACE_SYSTEM
+#define TRACE_SYSTEM kapi
+
+#if !defined(_TRACE_KAPI_H) || defined(TRACE_HEADER_MULTI_READ)
+#define _TRACE_KAPI_H
+
+#include <linux/tracepoint.h>
+
+/* Max length of the rendered "name=value, ..." parameter list. */
+#define KAPI_TP_PARAMS_LEN 256
+
+/*
+ * Emitted from the CONFIG_KAPI_RUNTIME_CHECKS syscall validation path for
+ * syscalls that have a KAPI specification: kapi_syscall_enter fires before
+ * parameter validation, kapi_syscall_exit after the handler returns.
+ * @name is the spec name, e.g. "sys_open".
+ *
+ * kapi_syscall_enter carries both the raw argument values (args[]) and, when
+ * the spec provides parameter metadata, a rendered "name=value" list (params,
+ * built by the caller): pointer-like values in hex, integers and fds in decimal.
+ */
+TRACE_EVENT(kapi_syscall_enter,
+
+	TP_PROTO(const char *name, int nargs, const s64 *args, const char *params),
+
+	TP_ARGS(name, nargs, args, params),
+
+	TP_STRUCT__entry(
+		__string(	name,	name	)
+		__field(	int,	nargs	)
+		__array(	u64,	args,	6	)
+		__string(	params,	params	)
+	),
+
+	TP_fast_assign(
+		__assign_str(name);
+		__entry->nargs = nargs;
+		memset(__entry->args, 0, sizeof(__entry->args));
+		if (args && nargs > 0)
+			memcpy(__entry->args, args,
+			       min_t(int, nargs, 6) * sizeof(__entry->args[0]));
+		__assign_str(params);
+	),
+
+	TP_printk("%s(%s)", __get_str(name), __get_str(params))
+);
+
+TRACE_EVENT(kapi_syscall_exit,
+
+	TP_PROTO(const char *name, long ret, bool spec_match),
+
+	TP_ARGS(name, ret, spec_match),
+
+	TP_STRUCT__entry(
+		__string(	name,		name		)
+		__field(	long,		ret		)
+		__field(	bool,		spec_match	)
+	),
+
+	TP_fast_assign(
+		__assign_str(name);
+		__entry->ret = ret;
+		__entry->spec_match = spec_match;
+	),
+
+	TP_printk("%s = %ld spec_match=%d",
+		  __get_str(name), __entry->ret, __entry->spec_match)
+);
+
+#endif /* _TRACE_KAPI_H */
+
+/* This part must be outside protection */
+#include <trace/define_trace.h>
diff --git a/kernel/api/kernel_api_spec.c b/kernel/api/kernel_api_spec.c
index c3b220104c04b..993674ce67789 100644
--- a/kernel/api/kernel_api_spec.c
+++ b/kernel/api/kernel_api_spec.c
@@ -751,6 +751,45 @@ EXPORT_SYMBOL_GPL(kapi_export_json);
 
 #ifdef CONFIG_KAPI_RUNTIME_CHECKS
 
+#define CREATE_TRACE_POINTS
+#include <trace/events/kapi.h>
+
+/*
+ * Render a syscall's parameters as a "name=value, ..." string for the
+ * kapi_syscall_enter tracepoint.  Names come from the spec; pointer-like
+ * values are shown in hex, integers and file descriptors in decimal.
+ */
+static void kapi_trace_format_params(const struct kernel_api_spec *spec,
+				     const s64 *args, int nargs,
+				     char *buf, size_t size)
+{
+	int i, used = 0;
+
+	buf[0] = '\0';
+	/* Bound by the caller-supplied arg count; the spec arity may differ. */
+	for (i = 0; args && i < nargs && i < 6; i++) {
+		const char *name = "arg";
+		bool dec = false;
+
+		if (i < spec->param_count) {
+			const struct kapi_param_spec *ps = &spec->params[i];
+
+			if (ps->name)
+				name = ps->name;
+			dec = ps->type == KAPI_TYPE_INT || ps->type == KAPI_TYPE_FD;
+		}
+
+		used += scnprintf(buf + used, size - used, "%s%s=",
+				  i ? ", " : "", name);
+		if (dec)
+			used += scnprintf(buf + used, size - used, "%lld",
+					  (long long)args[i]);
+		else
+			used += scnprintf(buf + used, size - used, "0x%llx",
+					  (unsigned long long)args[i]);
+	}
+}
+
 /**
  * kapi_validate_fd - Validate that a file descriptor value is in valid range
  * @fd: File descriptor to validate
@@ -1211,16 +1250,24 @@ EXPORT_SYMBOL_GPL(kapi_validate_param_with_context);
 int kapi_validate_syscall_params(const struct kernel_api_spec *spec,
 				 const s64 *params, int param_count)
 {
-	int i;
+	int i, ret = 0;
 
 	if (!spec || !params)
 		return 0;
 
+	if (trace_kapi_syscall_enter_enabled()) {
+		char pbuf[KAPI_TP_PARAMS_LEN];
+
+		kapi_trace_format_params(spec, params, param_count, pbuf, sizeof(pbuf));
+		trace_kapi_syscall_enter(spec->name, param_count, params, pbuf);
+	}
+
 	/* Validate that we have the expected number of parameters */
 	if (param_count != spec->param_count) {
 		pr_warn_ratelimited("API %s: parameter count mismatch (expected %u, got %d)\n",
 			spec->name, spec->param_count, param_count);
-		return -EINVAL;
+		ret = -EINVAL;
+		goto out;
 	}
 
 	/* Validate each parameter with context */
@@ -1230,12 +1277,22 @@ int kapi_validate_syscall_params(const struct kernel_api_spec *spec,
 		if (!kapi_validate_param_with_context(param_spec, params[i], params, param_count)) {
 			if (strncmp(spec->name, "sys_", 4) == 0) {
 				/* For syscalls, we can return EINVAL to userspace */
-				return -EINVAL;
+				ret = -EINVAL;
+				goto out;
 			}
 		}
 	}
 
-	return 0;
+out:
+	/*
+	 * Emit the exit event on the rejection path too (the wrapper
+	 * short-circuits the handler on a non-zero return), so every
+	 * kapi_syscall_enter has a matching kapi_syscall_exit.
+	 */
+	if (ret)
+		trace_kapi_syscall_exit(spec->name, ret, false);
+
+	return ret;
 }
 EXPORT_SYMBOL_GPL(kapi_validate_syscall_params);
 
@@ -1353,18 +1410,16 @@ EXPORT_SYMBOL_GPL(kapi_validate_return_value);
  */
 int kapi_validate_syscall_return(const struct kernel_api_spec *spec, s64 retval)
 {
+	bool valid = true;
+
 	if (!spec)
 		return 0;
 
-	/* Skip return validation if return spec was not defined */
-	if (spec->return_magic != KAPI_MAGIC_RETURN)
-		return 0;
+	/* Validate against the return spec when one was defined */
+	if (spec->return_magic == KAPI_MAGIC_RETURN)
+		valid = kapi_validate_return_value(spec, retval);
 
-	if (!kapi_validate_return_value(spec, retval)) {
-		/* Log the violation but don't change the return value */
-		pr_warn_ratelimited("KAPI: Syscall %s returned unspecified value %lld\n",
-				    spec->name, retval);
-	}
+	trace_kapi_syscall_exit(spec->name, retval, valid);
 
 	return 0;
 }
-- 
2.53.0


^ permalink raw reply	[flat|nested] 21+ messages in thread

* Re: [PATCH v5 05/11] kernel/api: add API specification for sys_open
  2026-10-08  8:49 ` [PATCH v5 05/11] kernel/api: add API specification for sys_open Sasha Levin
@ 2026-10-08 12:49   ` Serge E. Hallyn
  2026-10-08 13:13     ` Gregory Price
  0 siblings, 1 reply; 21+ messages in thread
From: Serge E. Hallyn @ 2026-10-08 12:49 UTC (permalink / raw)
  To: Sasha Levin
  Cc: linux-api, linux-kernel, linux-doc, linux-fsdevel, linux-kbuild,
	linux-kselftest, workflows, tools, x86, Thomas Gleixner,
	Paul E . McKenney, Greg Kroah-Hartman, Jonathan Corbet,
	Dmitry Vyukov, Randy Dunlap, Cyril Hrubis, Kees Cook, Jake Edge,
	David Laight, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

On Thu, Oct 08, 2026 at 04:49:45AM -0400, Sasha Levin wrote:
> Add KAPI-annotated kerneldoc for the sys_open system call in fs/open.c.
> 
> The specification documents parameter constraints (pathname, flags
> bitmask, permission mode), 24 error conditions, locking requirements,
> side effects, required capabilities, and usage examples.
> 
> Assisted-by: LLM
> Signed-off-by: Sasha Levin <sashal@kernel.org>

I know Kees and Jonathan and others asked for exactly this.  But one
downside to this is it makes just paging through fs/open.c a lot more
painful.  Maybe it's worth it.  Maybe "noone will ever do that again" bc
that's why we have ai and tools.  But a) that's how I've historically
done a lot of code research, b) IMO something like a manpages section 2
under Documentation/ would be a great place for this, and c) we can also
use tools to always sync these, or even show/edit in a single view when
you want  ('kdocedit fs/open.c').

> ---
>  fs/open.c | 324 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
>  1 file changed, 324 insertions(+)
> 
> diff --git a/fs/open.c b/fs/open.c
> index 6b1c14e684a93..92808ab20d52a 100644
> --- a/fs/open.c
> +++ b/fs/open.c
> @@ -1424,6 +1424,330 @@ int do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
>  }
>  
>  
> +/**
> + * sys_open - Open or create a file
> + * @filename: Pathname of the file to open or create
> + * @flags: File access mode and behavior flags (O_RDONLY, O_WRONLY, O_RDWR, etc.)
> + * @mode: File permission bits for newly created files (only with O_CREAT/O_TMPFILE)
> + *
> + * long-desc: Opens the file named by filename, relative to the current
> + *   working directory if the path is relative. With O_CREAT, the file is
> + *   created if it does not exist. With O_TMPFILE, filename must name an
> + *   existing directory, in which an unnamed file is created. A new file gets
> + *   mode & ~umask as its permission bits.
> + *
> + *   The low two bits of flags (O_ACCMODE) select the access mode: O_RDONLY,
> + *   O_WRONLY or O_RDWR. File creation and file status flags are ORed in.
> + *
> + *   File creation flags: O_CREAT, O_EXCL, O_NOCTTY, O_TRUNC, O_DIRECTORY,
> + *   O_NOFOLLOW, O_CLOEXEC, O_TMPFILE, O_EMPTYPATH. O_EMPTYPATH permits an
> + *   empty filename, which for open() refers to the current working directory.
> + *
> + *   File status flags: O_APPEND, FASYNC, O_DIRECT, O_DSYNC, O_LARGEFILE,
> + *   O_NOATIME, O_NONBLOCK (O_NDELAY), O_PATH, O_SYNC. These become part of the
> + *   file's open file description and can be retrieved with fcntl(F_GETFL). Only
> + *   O_APPEND, O_NONBLOCK, O_DIRECT, O_NOATIME and FASYNC can be changed later
> + *   with fcntl(F_SETFL).
> + *
> + *   On success the lowest-numbered file descriptor not currently open in the
> + *   process is returned.
> + *
> + *   On 64-bit systems, O_LARGEFILE is automatically added to the flags. On 32-bit
> + *   systems, files larger than 2GB require O_LARGEFILE to be explicitly set.
> + *
> + *   open() is equivalent to openat(AT_FDCWD, filename, flags, mode).
> + *
> + * contexts: process, sleepable
> + *
> + * param: filename
> + *   type: path, input
> + *   constraint-type: user_path
> + *   cdesc: Must be a valid null-terminated path string in user memory.
> + *     Maximum path length is PATH_MAX (4096 bytes) including null terminator.
> + *     For relative paths, resolution starts from current working directory.
> + *     The path is followed (symlinks resolved) unless O_NOFOLLOW is specified.
> + *
> + * param: flags
> + *   type: int, input
> + *   constraint-type: mask(O_RDONLY | O_WRONLY | O_RDWR | O_CREAT | O_EXCL | O_NOCTTY |
> + *                         O_TRUNC | O_APPEND | O_NONBLOCK | O_NDELAY | O_DSYNC | O_SYNC |
> + *                         FASYNC | O_DIRECT | O_LARGEFILE | O_DIRECTORY | O_NOFOLLOW |
> + *                         O_NOATIME | O_CLOEXEC | O_PATH | O_TMPFILE | O_EMPTYPATH)
> + *   cdesc: Should be one of O_RDONLY (0), O_WRONLY (1), or O_RDWR (2) as the
> + *     access mode. Additional flags may be ORed. O_CREAT combined with
> + *     O_DIRECTORY or O_TMPFILE, O_TMPFILE without O_DIRECTORY, and O_TMPFILE
> + *     with read-only mode return EINVAL. With O_PATH, open() silently drops
> + *     every other flag except O_DIRECTORY, O_NOFOLLOW, O_CLOEXEC and
> + *     O_EMPTYPATH (only openat2() rejects them with EINVAL). Unknown flags are
> + *     silently ignored for backward compatibility (unlike openat2 which
> + *     rejects them).
> + *
> + * param: mode
> + *   type: uint, input
> + *   cdesc: Only meaningful when O_CREAT or O_TMPFILE is specified in
> + *     flags. Specifies the file mode bits (permissions and setuid/setgid/sticky
> + *     bits) for a newly created file. The effective mode is (mode & ~umask).
> + *     When O_CREAT/O_TMPFILE is not set, mode is ignored. Mode values exceeding
> + *     S_IALLUGO (07777) are masked off.
> + *
> + * return:
> + *   type: int
> + *   check-type: fd
> + *   success: >= 0
> + *   desc: On success, returns a new file descriptor (non-negative integer).
> + *     The returned file descriptor is the lowest-numbered descriptor not
> + *     currently open for the process. On error, returns a negative error code.
> + *
> + * error: EACCES, Permission denied
> + *   desc: The requested access to the file is not allowed, or search permission
> + *     is denied for one of the directories in the path prefix of pathname, or
> + *     the file did not exist yet and write access to the parent directory is
> + *     not allowed, or O_TRUNC is specified but write permission is denied, or
> + *     pathname is a device special file on a nodev mount, or O_CREAT on an
> + *     existing FIFO or regular file in a sticky directory is refused by
> + *     protected_fifos or protected_regular, or a security module denies the
> + *     open.
> + *
> + * error: EAGAIN, Resource temporarily unavailable
> + *   desc: O_NONBLOCK was specified and a conflicting lease is held on the file,
> + *     so the open would have to wait for the lease to break. break_lease()
> + *     returns -EWOULDBLOCK, which has the same value as EAGAIN.
> + *
> + * error: EBUSY, Device or resource busy
> + *   desc: O_EXCL was specified in flags and pathname refers to a block device
> + *     that is in use by the system (e.g., it is mounted).
> + *
> + * error: EDQUOT, Disk quota exceeded
> + *   desc: O_CREAT is specified and the file does not exist, and the user's quota
> + *     of disk blocks or inodes on the filesystem has been exhausted.
> + *
> + * error: EEXIST, File exists
> + *   desc: O_CREAT and O_EXCL were specified in flags, but pathname already exists.
> + *     This error is atomic with respect to file creation - it prevents race
> + *     conditions (TOCTOU) when creating files.
> + *
> + * error: EFAULT, Bad address
> + *   desc: pathname points outside the process's accessible address space.
> + *
> + * error: EINTR, Interrupted system call
> + *   desc: A signal arrived while the open was blocked waiting for the partner
> + *     of a FIFO open (fifo_open), waiting for a conflicting lease to break
> + *     (__break_lease), or inside a driver's open method. The kernel-internal
> + *     -ERESTARTSYS is reported as EINTR unless the handler uses SA_RESTART.
> + *
> + * error: EINVAL, Invalid argument
> + *   desc: Returned for several conditions: (1) Invalid O_* flag combinations
> + *     (O_CREAT with O_DIRECTORY, O_CREAT with O_TMPFILE, O_TMPFILE without
> + *     O_DIRECTORY, O_TMPFILE with read-only access). (2) O_DIRECT requested
> + *     but the filesystem does not support it.
> + *
> + * error: EISDIR, Is a directory
> + *   desc: pathname refers to a directory and the access requested involved
> + *     writing (O_WRONLY, O_RDWR, or O_TRUNC). Also returned when O_CREAT is
> + *     specified and pathname names an existing directory or ends in a slash.
> + *
> + * error: ELOOP, Too many symbolic links
> + *   desc: Too many symbolic links were encountered in resolving pathname, or
> + *     O_NOFOLLOW was specified but pathname refers to a symbolic link. With
> + *     O_PATH and O_NOFOLLOW the symbolic link itself is opened instead.
> + *
> + * error: EMFILE, Too many open files
> + *   desc: The per-process limit on the number of open file descriptors has been
> + *     reached. This limit is RLIMIT_NOFILE (default typically 1024, max set by
> + *     /proc/sys/fs/nr_open).
> + *
> + * error: ENAMETOOLONG, File name too long
> + *   desc: pathname was too long, exceeding PATH_MAX (4096) bytes, or a single
> + *     path component exceeded NAME_MAX (usually 255) bytes.
> + *
> + * error: ENFILE, Too many open files in system
> + *   desc: The system-wide limit on the total number of open files has been
> + *     reached (/proc/sys/fs/file-max). Processes with CAP_SYS_ADMIN can exceed
> + *     this limit.
> + *
> + * error: ENODEV, No such device
> + *   desc: The filesystem or driver open method failed with ENODEV, or the
> + *     file's inode has no file operations assigned. A device special file with
> + *     no registered device fails with ENXIO instead.
> + *
> + * error: ENOENT, No such file or directory
> + *   desc: A directory component in pathname does not exist or is a dangling
> + *     symbolic link, or O_CREAT is not set and the named file does not exist,
> + *     or pathname is an empty string and O_EMPTYPATH is not specified.
> + *
> + * error: ENOMEM, Out of memory
> + *   desc: The kernel could not allocate sufficient memory for the file structure,
> + *     path lookup structures, or the filename buffer.
> + *
> + * error: ENOSPC, No space left on device
> + *   desc: O_CREAT was specified and the file does not exist, and the directory
> + *     or filesystem containing the file has no room for a new file entry.
> + *
> + * error: ENOTDIR, Not a directory
> + *   desc: A component used as a directory in pathname is not actually a directory,
> + *     or O_DIRECTORY was specified and pathname was not a directory.
> + *
> + * error: ENXIO, No such device or address
> + *   desc: O_NONBLOCK | O_WRONLY is set and the named file is a FIFO and no
> + *     process has the FIFO open for reading. Also returned when opening a device
> + *     special file whose device does not exist (chrdev_open, blkdev_open), or
> + *     when opening a socket inode.
> + *
> + * error: EOPNOTSUPP, Operation not supported
> + *   desc: The filesystem containing pathname does not support O_TMPFILE.
> + *
> + * error: EOVERFLOW, Value too large for defined data type
> + *   desc: pathname refers to a regular file that is too large to be opened.
> + *     This occurs on 32-bit systems without O_LARGEFILE when the file size
> + *     exceeds 2GB (2^31 - 1 bytes).
> + *
> + * error: EPERM, Operation not permitted
> + *   desc: O_NOATIME flag was specified but the effective UID of the caller did
> + *     not match the owner of the file and the caller is not privileged, or the
> + *     file is append-only and O_TRUNC was specified or write mode without
> + *     O_APPEND, or the file is immutable, or a seal prevents the operation.
> + *
> + * error: EROFS, Read-only file system
> + *   desc: pathname refers to a file on a read-only filesystem and write access
> + *     was requested.
> + *
> + * error: ETXTBSY, Text file busy
> + *   desc: Write access or O_TRUNC was requested for an executable image that
> + *     is currently being executed, or O_TRUNC was requested on an active swap
> + *     file. A swap file can otherwise be opened for writing.
> + *
> + * lock: files->file_lock
> + *   type: spinlock
> + *   acquired: true
> + *   released: true
> + *   desc: Acquired when allocating a file descriptor slot. Held briefly during
> + *     fd allocation via alloc_fd() and released before the syscall returns.
> + *
> + * lock: inode->i_rwsem (parent directory)
> + *   type: semaphore
> + *   acquired: true
> + *   released: true
> + *   desc: Conditional, taken only when the final component is not resolved by
> + *     the lockless dcache lookup. lookup_open() takes it exclusively with
> + *     inode_lock() when O_CREAT is set and shared with inode_lock_shared()
> + *     otherwise. Slow-path lookup of path components takes it shared. Released
> + *     when the lookup returns. The open path has no killable variant.
> + *
> + * lock: RCU read-side
> + *   type: rcu
> + *   acquired: true
> + *   released: true
> + *   desc: Path lookup uses RCU mode initially for performance. If RCU lookup
> + *     fails (returns -ECHILD), falls back to reference-based lookup.
> + *
> + * signal: Any signal
> + *   direction: receive
> + *   action: return
> + *   condition: When blocked in an interruptible wait
> + *   desc: The syscall may be interrupted while waiting for the partner of a
> + *     FIFO open (fifo_open), for a conflicting lease to break (__break_lease),
> + *     or inside a driver's open method. The wait returns -ERESTARTSYS, which
> + *     is restarted after the handler with SA_RESTART and reported as EINTR
> + *     otherwise.
> + *   errno: -EINTR
> + *   timing: during
> + *   restartable: yes
> + *
> + * side-effect: resource_create | alloc_memory
> + *   target: file descriptor, file structure, dentry cache
> + *   desc: Allocates a new file descriptor in the process's fd table. Allocates
> + *     a struct file from the filp slab cache. May allocate dentries and inodes
> + *     during path lookup. System-wide file count (nr_files) is incremented.
> + *   reversible: yes
> + *
> + * side-effect: filesystem
> + *   target: filesystem, inode
> + *   condition: When O_CREAT is specified and file doesn't exist
> + *   desc: Creates a new file on the filesystem. Creates new inode, allocates
> + *     data blocks as needed, and creates directory entry. Updates parent
> + *     directory mtime and ctime.
> + *   reversible: no
> + *
> + * side-effect: filesystem
> + *   target: file content
> + *   condition: When O_TRUNC is specified for existing file
> + *   desc: Truncates the file to zero length, releasing data blocks. Updates
> + *     file mtime and ctime. May trigger notifications to lease holders.
> + *   reversible: no
> + *
> + * side-effect: modify_state
> + *   target: inode timestamps
> + *   condition: When a symlink is followed, or O_TRUNC or O_CREAT takes effect
> + *   desc: Opening does not update the atime of the opened file. Reads update it
> + *     later, and O_NOATIME only affects those reads. Following a symlink in
> + *     the path may update the symlink's atime, subject to the mount atime
> + *     options. O_TRUNC and file creation update mtime and ctime.
> + *
> + * capability: CAP_DAC_OVERRIDE
> + *   type: bypass_check
> + *   allows: Bypass file read, write, and execute permission checks
> + *   without: Standard DAC (discretionary access control) checks are applied
> + *   condition: Checked when file permission would otherwise deny access
> + *
> + * capability: CAP_DAC_READ_SEARCH
> + *   type: bypass_check
> + *   allows: Bypass read permission on files and search permission on directories
> + *   without: Must have read permission on file or search permission on directory
> + *   condition: Checked during path traversal and file open
> + *
> + * capability: CAP_FOWNER
> + *   type: bypass_check
> + *   allows: Use O_NOATIME on files not owned by caller
> + *   without: O_NOATIME returns EPERM if caller is not file owner
> + *   condition: Checked when O_NOATIME is specified and caller is not owner
> + *
> + * capability: CAP_SYS_ADMIN
> + *   type: increase_limit
> + *   allows: Exceed the system-wide file limit (file-max)
> + *   without: Returns ENFILE when system limit is reached
> + *   condition: Checked in alloc_empty_file() when nr_files >= max_files
> + *
> + * constraint: RLIMIT_NOFILE (per-process fd limit)
> + *   desc: The returned file descriptor must be less than the process's
> + *     RLIMIT_NOFILE limit. Default is typically 1024, maximum is controlled
> + *     by /proc/sys/fs/nr_open (default 1048576). Exceeding returns EMFILE.
> + *   expr: fd < rlimit(RLIMIT_NOFILE)
> + *
> + * constraint: file-max (system-wide limit)
> + *   desc: System-wide limit on open files in /proc/sys/fs/file-max. Processes
> + *     without CAP_SYS_ADMIN receive ENFILE when this limit is reached. The
> + *     limit is computed based on system memory at boot time.
> + *   expr: nr_files < files_stat.max_files || capable(CAP_SYS_ADMIN)
> + *
> + * constraint: PATH_MAX
> + *   desc: Maximum length of pathname including null terminator is PATH_MAX
> + *     (4096 bytes). Individual path components must not exceed NAME_MAX (255).
> + *
> + * examples: fd = open("/etc/passwd", O_RDONLY);  // Read existing file
> + *   fd = open("/tmp/newfile", O_WRONLY | O_CREAT | O_TRUNC, 0644);  // Create/truncate
> + *   fd = open("/tmp/lockfile", O_WRONLY | O_CREAT | O_EXCL, 0600);  // Exclusive create
> + *   fd = open("/dev/null", O_RDWR);  // Open device
> + *   fd = open("/tmp", O_RDONLY | O_DIRECTORY);  // Open directory
> + *   fd = open("/tmp", O_TMPFILE | O_RDWR, 0600);  // Anonymous temp file
> + *
> + * notes: O_RDONLY is defined as 0, so (flags & O_RDONLY) always evaluates to zero.
> + *   Test access mode using (flags & O_ACCMODE) == O_RDONLY.
> + *
> + *   When O_CREAT is specified without O_EXCL, there is a race condition between
> + *   testing for file existence and creating it. Use O_CREAT | O_EXCL for atomic
> + *   exclusive file creation.
> + *
> + *   O_CLOEXEC should be used in multithreaded programs to prevent file descriptor
> + *   leaks to child processes between fork() and execve().
> + *
> + *   O_DIRECT has alignment requirements that vary by filesystem. Use statx()
> + *   with STATX_DIOALIGN (Linux 6.1+) to query requirements. Unaligned I/O may
> + *   fail with EINVAL or fall back to buffered I/O.
> + *
> + *   O_PATH opens a file descriptor that can be used only for certain operations
> + *   (fstat, dup, fcntl, close, fchdir on directories, as dirfd for *at() calls).
> + *   I/O operations will fail with EBADF.
> + */
>  SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
>  {
>  	if (force_o_largefile())
> -- 
> 2.53.0
> 

^ permalink raw reply	[flat|nested] 21+ messages in thread

* Re: [PATCH v5 05/11] kernel/api: add API specification for sys_open
  2026-10-08 12:49   ` Serge E. Hallyn
@ 2026-10-08 13:13     ` Gregory Price
  2026-10-08 14:20       ` Serge E. Hallyn
  0 siblings, 1 reply; 21+ messages in thread
From: Gregory Price @ 2026-10-08 13:13 UTC (permalink / raw)
  To: Serge E. Hallyn
  Cc: Sasha Levin, linux-api, linux-kernel, linux-doc, linux-fsdevel,
	linux-kbuild, linux-kselftest, workflows, tools, x86,
	Thomas Gleixner, Paul E . McKenney, Greg Kroah-Hartman,
	Jonathan Corbet, Dmitry Vyukov, Randy Dunlap, Cyril Hrubis,
	Kees Cook, Jake Edge, David Laight, Gabriele Paoloni,
	Mauro Carvalho Chehab, Christian Brauner, Alexander Viro,
	Andrew Morton, Masahiro Yamada, Shuah Khan, Arnd Bergmann,
	Nathan Chancellor, Steven Rostedt, Masami Hiramatsu,
	Mathieu Desnoyers

On Thu, Oct 08, 2026 at 07:49:34AM -0500, Serge E. Hallyn wrote:
> On Thu, Oct 08, 2026 at 04:49:45AM -0400, Sasha Levin wrote:
> > Add KAPI-annotated kerneldoc for the sys_open system call in fs/open.c.
> > 
> > The specification documents parameter constraints (pathname, flags
> > bitmask, permission mode), 24 error conditions, locking requirements,
> > side effects, required capabilities, and usage examples.
> > 
> > Assisted-by: LLM
> > Signed-off-by: Sasha Levin <sashal@kernel.org>
> 
> I know Kees and Jonathan and others asked for exactly this.  But one
> downside to this is it makes just paging through fs/open.c a lot more
> painful.  Maybe it's worth it.  Maybe "noone will ever do that again" bc
> that's why we have ai and tools.  But a) that's how I've historically
> done a lot of code research, b) IMO something like a manpages section 2
> under Documentation/ would be a great place for this, and c) we can also
> use tools to always sync these, or even show/edit in a single view when
> you want  ('kdocedit fs/open.c').
>

In many, many other projects i've worked on, these docs are placed in
the header as opposed to the .c file, but I understand there is some
pain that comes with ifdef.

Keeping it in the header ties the definition to exactly the location
external users import to find the function - so it makes sense.

But separating the contracts from the code guarantees they'll go stale,
so I don't think shoving it in Documentation/ does anyone any good.

~Gregory

^ permalink raw reply	[flat|nested] 21+ messages in thread

* Re: [PATCH v5 05/11] kernel/api: add API specification for sys_open
  2026-10-08 13:13     ` Gregory Price
@ 2026-10-08 14:20       ` Serge E. Hallyn
  2026-10-08 14:37         ` Sasha Levin
  2026-10-08 16:12         ` David Laight
  0 siblings, 2 replies; 21+ messages in thread
From: Serge E. Hallyn @ 2026-10-08 14:20 UTC (permalink / raw)
  To: Gregory Price
  Cc: Sasha Levin, linux-api, linux-kernel, linux-doc, linux-fsdevel,
	linux-kbuild, linux-kselftest, workflows, tools, x86,
	Thomas Gleixner, Paul E . McKenney, Greg Kroah-Hartman,
	Jonathan Corbet, Dmitry Vyukov, Randy Dunlap, Cyril Hrubis,
	Kees Cook, Jake Edge, David Laight, Gabriele Paoloni,
	Mauro Carvalho Chehab, Christian Brauner, Alexander Viro,
	Andrew Morton, Masahiro Yamada, Shuah Khan, Arnd Bergmann,
	Nathan Chancellor, Steven Rostedt, Masami Hiramatsu,
	Mathieu Desnoyers

On Thu, Oct 08, 2026 at 09:13:38AM -0400, Gregory Price wrote:
> On Thu, Oct 08, 2026 at 07:49:34AM -0500, Serge E. Hallyn wrote:
> > On Thu, Oct 08, 2026 at 04:49:45AM -0400, Sasha Levin wrote:
> > > Add KAPI-annotated kerneldoc for the sys_open system call in fs/open.c.
> > > 
> > > The specification documents parameter constraints (pathname, flags
> > > bitmask, permission mode), 24 error conditions, locking requirements,
> > > side effects, required capabilities, and usage examples.
> > > 
> > > Assisted-by: LLM
> > > Signed-off-by: Sasha Levin <sashal@kernel.org>
> > 
> > I know Kees and Jonathan and others asked for exactly this.  But one
> > downside to this is it makes just paging through fs/open.c a lot more
> > painful.  Maybe it's worth it.  Maybe "noone will ever do that again" bc
> > that's why we have ai and tools.  But a) that's how I've historically
> > done a lot of code research, b) IMO something like a manpages section 2
> > under Documentation/ would be a great place for this, and c) we can also
> > use tools to always sync these, or even show/edit in a single view when
> > you want  ('kdocedit fs/open.c').
> >
> 
> In many, many other projects i've worked on, these docs are placed in
> the header as opposed to the .c file, but I understand there is some
> pain that comes with ifdef.
> 
> Keeping it in the header ties the definition to exactly the location
> external users import to find the function - so it makes sense.
> 
> But separating the contracts from the code guarantees they'll go stale,

OTOH these descriptions are so long that IMO they are guaranteed to go
stale anyway :)  While I'm editing a return value at the bottom of the
fn, most or all of the description is already going to be off my
terminal.

> so I don't think shoving it in Documentation/ does anyone any good.

If every build auto-generates an update and then looks for and flags
meaningful changes (API breakages), then a) that is more reliable and b)
it doesn't matter where the docs are.

Even if there's just a three line comment above a fn, history proves
that it will not reliably stay in sync as the fn changes.  An automation
step/check is needed.

> ~Gregory

^ permalink raw reply	[flat|nested] 21+ messages in thread

* Re: [PATCH v5 05/11] kernel/api: add API specification for sys_open
  2026-10-08 14:20       ` Serge E. Hallyn
@ 2026-10-08 14:37         ` Sasha Levin
  2026-10-08 14:46           ` Serge E. Hallyn
  2026-10-08 16:12         ` David Laight
  1 sibling, 1 reply; 21+ messages in thread
From: Sasha Levin @ 2026-10-08 14:37 UTC (permalink / raw)
  To: Serge E. Hallyn
  Cc: Gregory Price, linux-api, linux-kernel, linux-doc, linux-fsdevel,
	linux-kbuild, linux-kselftest, workflows, tools, x86,
	Thomas Gleixner, Paul E . McKenney, Greg Kroah-Hartman,
	Jonathan Corbet, Dmitry Vyukov, Randy Dunlap, Cyril Hrubis,
	Kees Cook, Jake Edge, David Laight, Gabriele Paoloni,
	Mauro Carvalho Chehab, Christian Brauner, Alexander Viro,
	Andrew Morton, Masahiro Yamada, Shuah Khan, Arnd Bergmann,
	Nathan Chancellor, Steven Rostedt, Masami Hiramatsu,
	Mathieu Desnoyers

On Thu, Oct 08, 2026 at 09:20:01AM -0500, Serge E. Hallyn wrote:
>On Thu, Oct 08, 2026 at 09:13:38AM -0400, Gregory Price wrote:
>> On Thu, Oct 08, 2026 at 07:49:34AM -0500, Serge E. Hallyn wrote:
>> > On Thu, Oct 08, 2026 at 04:49:45AM -0400, Sasha Levin wrote:
>> > > Add KAPI-annotated kerneldoc for the sys_open system call in fs/open.c.
>> > >
>> > > The specification documents parameter constraints (pathname, flags
>> > > bitmask, permission mode), 24 error conditions, locking requirements,
>> > > side effects, required capabilities, and usage examples.
>> > >
>> > > Assisted-by: LLM
>> > > Signed-off-by: Sasha Levin <sashal@kernel.org>
>> >
>> > I know Kees and Jonathan and others asked for exactly this.  But one
>> > downside to this is it makes just paging through fs/open.c a lot more
>> > painful.  Maybe it's worth it.  Maybe "noone will ever do that again" bc
>> > that's why we have ai and tools.  But a) that's how I've historically
>> > done a lot of code research, b) IMO something like a manpages section 2
>> > under Documentation/ would be a great place for this, and c) we can also
>> > use tools to always sync these, or even show/edit in a single view when
>> > you want  ('kdocedit fs/open.c').
>> >
>>
>> In many, many other projects i've worked on, these docs are placed in
>> the header as opposed to the .c file, but I understand there is some
>> pain that comes with ifdef.
>>
>> Keeping it in the header ties the definition to exactly the location
>> external users import to find the function - so it makes sense.
>>
>> But separating the contracts from the code guarantees they'll go stale,

My plan is to be able to have the more detailed part of these specs somewhere
else, specially as they will grow some more, but I didn't want to add this
complexity right now.

>OTOH these descriptions are so long that IMO they are guaranteed to go
>stale anyway :)  While I'm editing a return value at the bottom of the
>fn, most or all of the description is already going to be off my
>terminal.
>
>> so I don't think shoving it in Documentation/ does anyone any good.
>
>If every build auto-generates an update and then looks for and flags
>meaningful changes (API breakages), then a) that is more reliable and b)
>it doesn't matter where the docs are.
>
>Even if there's just a three line comment above a fn, history proves
>that it will not reliably stay in sync as the fn changes.  An automation
>step/check is needed.

Please see CONFIG_KAPI_RUNTIME_CHECKS added by this series.

-- 
Thanks,
Sasha

^ permalink raw reply	[flat|nested] 21+ messages in thread

* Re: [PATCH v5 05/11] kernel/api: add API specification for sys_open
  2026-10-08 14:37         ` Sasha Levin
@ 2026-10-08 14:46           ` Serge E. Hallyn
  2026-10-08 15:23             ` Sasha Levin
  0 siblings, 1 reply; 21+ messages in thread
From: Serge E. Hallyn @ 2026-10-08 14:46 UTC (permalink / raw)
  To: Sasha Levin
  Cc: Gregory Price, linux-api, linux-kernel, linux-doc, linux-fsdevel,
	linux-kbuild, linux-kselftest, workflows, tools, x86,
	Thomas Gleixner, Paul E . McKenney, Greg Kroah-Hartman,
	Jonathan Corbet, Dmitry Vyukov, Randy Dunlap, Cyril Hrubis,
	Kees Cook, Jake Edge, David Laight, Gabriele Paoloni,
	Mauro Carvalho Chehab, Christian Brauner, Alexander Viro,
	Andrew Morton, Masahiro Yamada, Shuah Khan, Arnd Bergmann,
	Nathan Chancellor, Steven Rostedt, Masami Hiramatsu,
	Mathieu Desnoyers

On Thu, Oct 08, 2026 at 10:37:59AM -0400, Sasha Levin wrote:
> On Thu, Oct 08, 2026 at 09:20:01AM -0500, Serge E. Hallyn wrote:
> > On Thu, Oct 08, 2026 at 09:13:38AM -0400, Gregory Price wrote:
> > > On Thu, Oct 08, 2026 at 07:49:34AM -0500, Serge E. Hallyn wrote:
> > > > On Thu, Oct 08, 2026 at 04:49:45AM -0400, Sasha Levin wrote:
> > > > > Add KAPI-annotated kerneldoc for the sys_open system call in fs/open.c.
> > > > >
> > > > > The specification documents parameter constraints (pathname, flags
> > > > > bitmask, permission mode), 24 error conditions, locking requirements,
> > > > > side effects, required capabilities, and usage examples.
> > > > >
> > > > > Assisted-by: LLM
> > > > > Signed-off-by: Sasha Levin <sashal@kernel.org>
> > > >
> > > > I know Kees and Jonathan and others asked for exactly this.  But one
> > > > downside to this is it makes just paging through fs/open.c a lot more
> > > > painful.  Maybe it's worth it.  Maybe "noone will ever do that again" bc
> > > > that's why we have ai and tools.  But a) that's how I've historically
> > > > done a lot of code research, b) IMO something like a manpages section 2
> > > > under Documentation/ would be a great place for this, and c) we can also
> > > > use tools to always sync these, or even show/edit in a single view when
> > > > you want  ('kdocedit fs/open.c').
> > > >
> > > 
> > > In many, many other projects i've worked on, these docs are placed in
> > > the header as opposed to the .c file, but I understand there is some
> > > pain that comes with ifdef.
> > > 
> > > Keeping it in the header ties the definition to exactly the location
> > > external users import to find the function - so it makes sense.
> > > 
> > > But separating the contracts from the code guarantees they'll go stale,
> 
> My plan is to be able to have the more detailed part of these specs somewhere
> else, specially as they will grow some more, but I didn't want to add this
> complexity right now.
> 
> > OTOH these descriptions are so long that IMO they are guaranteed to go
> > stale anyway :)  While I'm editing a return value at the bottom of the
> > fn, most or all of the description is already going to be off my
> > terminal.
> > 
> > > so I don't think shoving it in Documentation/ does anyone any good.
> > 
> > If every build auto-generates an update and then looks for and flags
> > meaningful changes (API breakages), then a) that is more reliable and b)
> > it doesn't matter where the docs are.
> > 
> > Even if there's just a three line comment above a fn, history proves
> > that it will not reliably stay in sync as the fn changes.  An automation
> > step/check is needed.
> 
> Please see CONFIG_KAPI_RUNTIME_CHECKS added by this series.

My point was just that whether the docs are inline or not, they will go
stale without that automation.  So if that's the case, then the docs do
not need to be inline.

^ permalink raw reply	[flat|nested] 21+ messages in thread

* Re: [PATCH v5 05/11] kernel/api: add API specification for sys_open
  2026-10-08 14:46           ` Serge E. Hallyn
@ 2026-10-08 15:23             ` Sasha Levin
  0 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08 15:23 UTC (permalink / raw)
  To: Serge E. Hallyn
  Cc: Gregory Price, linux-api, linux-kernel, linux-doc, linux-fsdevel,
	linux-kbuild, linux-kselftest, workflows, tools, x86,
	Thomas Gleixner, Paul E . McKenney, Greg Kroah-Hartman,
	Jonathan Corbet, Dmitry Vyukov, Randy Dunlap, Cyril Hrubis,
	Kees Cook, Jake Edge, David Laight, Gabriele Paoloni,
	Mauro Carvalho Chehab, Christian Brauner, Alexander Viro,
	Andrew Morton, Masahiro Yamada, Shuah Khan, Arnd Bergmann,
	Nathan Chancellor, Steven Rostedt, Masami Hiramatsu,
	Mathieu Desnoyers

On Thu, Oct 08, 2026 at 09:46:01AM -0500, Serge E. Hallyn wrote:
>My point was just that whether the docs are inline or not, they will go
>stale without that automation.  So if that's the case, then the docs do
>not need to be inline.

Ah, sorry - got it.

The way I saw it working out is that the core part would be inline, but the
"speccy" part would move to a different location.  

Ultimately, this is the call of the subsystem where those specs live, and I see
different subsystems taking different approaches to this.

-- 
Thanks,
Sasha

^ permalink raw reply	[flat|nested] 21+ messages in thread

* Re: [PATCH v5 05/11] kernel/api: add API specification for sys_open
  2026-10-08 14:20       ` Serge E. Hallyn
  2026-10-08 14:37         ` Sasha Levin
@ 2026-10-08 16:12         ` David Laight
  2026-10-08 16:16           ` Serge E. Hallyn
  1 sibling, 1 reply; 21+ messages in thread
From: David Laight @ 2026-10-08 16:12 UTC (permalink / raw)
  To: Serge E. Hallyn
  Cc: Gregory Price, Sasha Levin, linux-api, linux-kernel, linux-doc,
	linux-fsdevel, linux-kbuild, linux-kselftest, workflows, tools,
	x86, Thomas Gleixner, Paul E . McKenney, Greg Kroah-Hartman,
	Jonathan Corbet, Dmitry Vyukov, Randy Dunlap, Cyril Hrubis,
	Kees Cook, Jake Edge, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

On Thu, 8 Oct 2026 09:20:01 -0500
"Serge E. Hallyn" <serge@hallyn.com> wrote:

...
> 
> Even if there's just a three line comment above a fn, history proves
> that it will not reliably stay in sync as the fn changes.  An automation
> step/check is needed.

The only way it can possibly stay in step is to have the compiler process
the same source text.
Then, if you add/change a function parameter you'd be pretty much forced
to add/change the comment.
Return values would have to be documented at the C return statement and
really as an extra parameter to the return.

But, IMHO, the whole thing adds too much bloat to the source files.
When you 'grep' a source file, you don't really want another match in
a big comment at the top of every function.

David

> 
> > ~Gregory  


^ permalink raw reply	[flat|nested] 21+ messages in thread

* Re: [PATCH v5 05/11] kernel/api: add API specification for sys_open
  2026-10-08 16:12         ` David Laight
@ 2026-10-08 16:16           ` Serge E. Hallyn
  2026-10-08 22:47             ` Sasha Levin
  0 siblings, 1 reply; 21+ messages in thread
From: Serge E. Hallyn @ 2026-10-08 16:16 UTC (permalink / raw)
  To: David Laight
  Cc: Gregory Price, Sasha Levin, linux-api, linux-kernel, linux-doc,
	linux-fsdevel, linux-kbuild, linux-kselftest, workflows, tools,
	x86, Thomas Gleixner, Paul E . McKenney, Greg Kroah-Hartman,
	Jonathan Corbet, Dmitry Vyukov, Randy Dunlap, Cyril Hrubis,
	Kees Cook, Jake Edge, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

On Thu, Oct 08, 2026 at 05:12:05PM +0100, David Laight wrote:
> On Thu, 8 Oct 2026 09:20:01 -0500
> "Serge E. Hallyn" <serge@hallyn.com> wrote:
> 
> ...
> > 
> > Even if there's just a three line comment above a fn, history proves
> > that it will not reliably stay in sync as the fn changes.  An automation
> > step/check is needed.
> 
> The only way it can possibly stay in step is to have the compiler process
> the same source text.
> Then, if you add/change a function parameter you'd be pretty much forced
> to add/change the comment.
> Return values would have to be documented at the C return statement and
> really as an extra parameter to the return.
> 
> But, IMHO, the whole thing adds too much bloat to the source files.
> When you 'grep' a source file, you don't really want another match in
> a big comment at the top of every function.
> 
> David

That's why I feel all this info that Sasha has added should go into a
Documentation/uapi/ autogenerated directory.  Auto-updated on every build,
with warnings if the result has changed.

-serge

^ permalink raw reply	[flat|nested] 21+ messages in thread

* Re: [PATCH v5 05/11] kernel/api: add API specification for sys_open
  2026-10-08 16:16           ` Serge E. Hallyn
@ 2026-10-08 22:47             ` Sasha Levin
  0 siblings, 0 replies; 21+ messages in thread
From: Sasha Levin @ 2026-10-08 22:47 UTC (permalink / raw)
  To: Serge E. Hallyn
  Cc: David Laight, Gregory Price, linux-api, linux-kernel, linux-doc,
	linux-fsdevel, linux-kbuild, linux-kselftest, workflows, tools,
	x86, Thomas Gleixner, Paul E . McKenney, Greg Kroah-Hartman,
	Jonathan Corbet, Dmitry Vyukov, Randy Dunlap, Cyril Hrubis,
	Kees Cook, Jake Edge, Gabriele Paoloni, Mauro Carvalho Chehab,
	Christian Brauner, Alexander Viro, Andrew Morton,
	Masahiro Yamada, Shuah Khan, Arnd Bergmann, Nathan Chancellor,
	Steven Rostedt, Masami Hiramatsu, Mathieu Desnoyers

On Thu, Oct 08, 2026 at 11:16:46AM -0500, Serge E. Hallyn wrote:
>On Thu, Oct 08, 2026 at 05:12:05PM +0100, David Laight wrote:
>> On Thu, 8 Oct 2026 09:20:01 -0500
>> "Serge E. Hallyn" <serge@hallyn.com> wrote:
>>
>> ...
>> >
>> > Even if there's just a three line comment above a fn, history proves
>> > that it will not reliably stay in sync as the fn changes.  An automation
>> > step/check is needed.
>>
>> The only way it can possibly stay in step is to have the compiler process
>> the same source text.
>> Then, if you add/change a function parameter you'd be pretty much forced
>> to add/change the comment.
>> Return values would have to be documented at the C return statement and
>> really as an extra parameter to the return.
>>
>> But, IMHO, the whole thing adds too much bloat to the source files.
>> When you 'grep' a source file, you don't really want another match in
>> a big comment at the top of every function.

I guess I don't mind either way: I heard both sides of this argument, and the
spec workflow is okay with either, so if the relevant maintainer asks for
changes here I'm more than happy to reformat this.

Which is, I think, the advantage here: it works just fine for the various
"kingdoms" we have in kernel-land.

-- 
Thanks,
Sasha

^ permalink raw reply	[flat|nested] 21+ messages in thread

end of thread, other threads:[~2026-10-08 22:47 UTC | newest]

Thread overview: 21+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
2026-10-08  8:49 ` [PATCH v5 01/11] kernel/api: introduce kernel API specification framework Sasha Levin
2026-10-08  8:49 ` [PATCH v5 02/11] kernel/api: enable kerneldoc-based API specifications Sasha Levin
2026-10-08  8:49 ` [PATCH v5 03/11] kernel/api: add debugfs interface for kernel " Sasha Levin
2026-10-08  8:49 ` [PATCH v5 04/11] tools/kapi: add kernel API specification extraction tool Sasha Levin
2026-10-08  8:49 ` [PATCH v5 05/11] kernel/api: add API specification for sys_open Sasha Levin
2026-10-08 12:49   ` Serge E. Hallyn
2026-10-08 13:13     ` Gregory Price
2026-10-08 14:20       ` Serge E. Hallyn
2026-10-08 14:37         ` Sasha Levin
2026-10-08 14:46           ` Serge E. Hallyn
2026-10-08 15:23             ` Sasha Levin
2026-10-08 16:12         ` David Laight
2026-10-08 16:16           ` Serge E. Hallyn
2026-10-08 22:47             ` Sasha Levin
2026-10-08  8:49 ` [PATCH v5 06/11] kernel/api: add API specification for sys_close Sasha Levin
2026-10-08  8:49 ` [PATCH v5 07/11] kernel/api: add API specification for sys_read Sasha Levin
2026-10-08  8:49 ` [PATCH v5 08/11] kernel/api: add API specification for sys_write Sasha Levin
2026-10-08  8:49 ` [PATCH v5 09/11] kernel/api: add runtime verification selftest Sasha Levin
2026-10-08  8:49 ` [PATCH v5 10/11] kernel/api: add API specification for sys_madvise Sasha Levin
2026-10-08  8:49 ` [PATCH v5 11/11] kernel/api: add syscall enter/exit tracepoints Sasha Levin

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox

all inboxes | Powered by JetHome®