Kernel KVM virtualization development
 help / color / mirror / Atom feed
From: sashiko-bot@kernel.org
To: "Jiri Pirko" <jiri@resnulli.us>
Cc: kvm@vger.kernel.org
Subject: Re: [PATCH RFC v2 01/15] ctlv: Add the schema language and generator
Date: Fri, 13 Nov 2026 16:23:20 +0000	[thread overview]
Message-ID: <sashiko-outbox-213188@kernel.org> (raw)
In-Reply-To: <20261113155515.2510194-2-jiri@resnulli.us>

Thank you for your contribution! Sashiko AI review found 7 potential
issues to consider:
- [Low] ctlv: compat: broken $include and shared field reconstruction in
  _foreign()
- [Low] ctlv: semantics: filled extension point accepted as normal
  record in _named()
- [Low] ctlv: semantics: order-dependent false record cycle error in
  _held()
- [Low] ctlv: spec: unresolved relative Path parent traversal in Paths
  and load()
- [Low] ctlv: semantics: single-pass _check_fills() misses nested
  extension sites
- [Low] ctlv: spec: _blob() accepts empty argument as valid backing list
- [Low] ctlv: missing composed size and fill depth checks in
  _check_composed()
--

commit c7763c8379c0de88c3274d5b9d07a09e53102368
Author: Jiri Pirko <jiri@resnulli.us>

ctlv: Add the schema language and generator

This commit adds the YAML meta-schemas for CTLV family specifications, the
family registry, and generated-file manifests, along with the ctlvgen tool
to validate specifications, check ABI compatibility, and emit UAPI and
kernel C files.

> diff --git a/tools/ctlv/ctlvgen/lib/compat.py b/tools/ctlv/ctlvgen/lib/compat.py
> new file mode 100644
> index 0000000000000..76bd14f60750b
> --- /dev/null
> +++ b/tools/ctlv/ctlvgen/lib/compat.py
[ ... ]
> +def _foreign(family: semantics.Family, record: semantics.Record,
> +             own: set[int]) -> dict[str, bool]:
> +    """What a record has that it does not declare: placed records and includes.
> +
> +    An include is one member, optional when it made a field optional that the
> +    record it splices requires.
> +    """
> +    members = {}
> +    for member in record.members:
> +        field = member.field
> +        if field.type in own:
> +            continue
> +        if field.shared:
> +            members[f'record {field.name}'] = member.required
> +            continue
> +        key = f'$include {field.record}'
> +        source = family.records[field.record]
> +        declared = any(item.required for item in source.members
> +                       if item.field.type == field.type)
> +        members.setdefault(key, True)
> +        if declared and not member.required:
> +            members[key] = False
> +    return members

[Severity: Low]
Does reconstructing foreign members from the flattened record.members
corrupt ABI snapshots and trigger false ABI breaks?

Because _include() splices target.members directly into the including
record while keeping each field's original field.record and field.shared
values, if record A places a shared field f or includes record C and
record B has "$include: A", _foreign() records "record f" or "$include C"
in B's snapshot instead of "$include A".

In addition, _foreign() infers whether "$include X" was written with "?"
by checking if any field in X is required while its spliced member is
optional.  If a request record has "$include: X?" and the last required
field in X is later relaxed to optional, declared becomes False for every
field of X, so members['$include X'] flips from False to True and
_compare_members() reports a false "$include X became required" ABI break.

> diff --git a/tools/ctlv/ctlvgen/lib/semantics.py b/tools/ctlv/ctlvgen/lib/semantics.py
> new file mode 100644
> index 0000000000000..86725e7c1d693
> --- /dev/null
> +++ b/tools/ctlv/ctlvgen/lib/semantics.py
[ ... ]
> +    def _named(self, name: str, where: str | None) -> Record:
> +        """Return the named record or field of records: called name."""
> +        if name in self.named:
> +            return self.named[name]
> +        if name not in self.declared:
> +            if name in self.extensions:
> +                raise CtlvError(where, f'{name} is an extension point, which '
> +                                       'extension names')
> +            raise CtlvError(where, f'no record is called {name}')

[Severity: Low]
Can a filled extension point be referenced as a normal named record via
"record <name>" or "$include: <name>"?

When a descendant family fills an inherited extension point in records:,
_add_records() adds that name to self.declared and self.named.  Because
_named() only checks self.extensions when name is absent from self.named
and self.declared, referencing the extension point via "record <name>" or
"$include: <name>" in the filling family or a further descendant bypasses
the self.extensions check and treats the fill as a regular reusable
record.

> +        if name in self.building:
> +            raise CtlvError(where, 'record cycle: ' +
> +                                   ' -> '.join(self.building + (name,)))
[ ... ]
> +    def _held(self, name: str, where: str) -> str:
> +        """Return the path of the named record a field holds."""
> +        self.used.add(name)
> +        if name in self.building:
> +            # A record holding itself, which the tree re-enters.
> +            is_field = self.declared[name][3].body is None
> +        else:
> +            is_field = self._named(name, where).role == 'field'
> +        if is_field:
> +            raise CtlvError(where, f'record {name} holds a named record, and '
> +                                   f'{name} is a field of records:, which '
> +                                   'bare record places')
> +        return name

[Severity: Low]
Does _held() raise an order-dependent false "record cycle" error when a
held record includes the enclosing record?

If record A has a field "b?: record B" and record B has "$include: A",
declaring B before A succeeds because _held('B') sees 'B' in
self.building and inspects self.declared['B'][3].body directly.  If A is
declared before B, _named('A') calls _held('B') while 'A' is in
self.building, and _held('B') calls _named('B'), which resolves
"$include: A" and fails with "record cycle: A -> B -> A".  Checking
self.declared[name][3].body whenever name is in self.declared avoids
resolving B while A is still being built.

[ ... ]

> +    def _check_fills(self, fills: tuple[Fill, ...]) -> None:
> +        """Hold each fill to every place its extension point sits.
> +
> +        A fill nested in another is met after it, so its places are known.
> +        """
> +        for fill in fills:
> +            where = self.declared[fill.name][1]
> +            for site, context, level in self.sites.get(fill.name, []):
> +                self._check_subtree(f'{where} in {site}', fill.root, context,
> +                                    level)

[Severity: Low]
Can a single pass in _check_fills() miss extension sites when a later fill
reuses a record containing an earlier-filled extension point?

 If an ancestor family declares extension point ext-b inside a named record
rec-b and extension point ext-a in an op, and a descendant fills ext-b
before ext-a (either earlier in records: or in an intermediate parent
family) while filling ext-a using rec-b, ext-b precedes ext-a in fills.
_check_fills() then checks ext-b before visiting ext-a, so the site for
ext-b inside ext-a is appended to self.sites['ext-b'] after ext-b's loop
has already finished, skipping context and depth validation for ext-b at
that site.

[ ... ]

> +def _check_composed(family: Family) -> None:
> +    def walk_composed(where: str, node: Node) -> int:
> +        members = composed_members(family, node)
> +        required = sum(not optional for _child, optional in members)
> +        optional = len(members) - required
> +
> +        _check_count(where, 'composed required fields', required,
> +                     spec.U32_MAX)
> +        _check_count(where, 'composed optional fields', optional,
> +                     spec.U32_MAX)
> +        count = 1
> +        for child, _optional in members:
> +            count += walk_composed(where, child)
> +        return count
> +
> +    for op in family.ops:
> +        total = 0
> +        for which, root in (('request', op.request), ('reply', op.reply)):
> +            if root is None:
> +                continue
> +            where = f'{family.path}: op {op.name} {which}'
> +            count = walk_composed(where, root)
> +            _check_count(where, 'composed schema nodes', count,
> +                         spec.U32_MAX)
> +            total += count
> +        _check_count(f'{family.path}: op {op.name}',
> +                     'composed request and reply nodes', total,
> +                     spec.U32_MAX)

[Severity: Low]
Does _check_composed() miss checking composed op message sizes against
MAX_INLINE_MESSAGE_LEN?

_add_ops() runs sizes.compute() on the uncomposed op tree where extension
points are empty, while sizes.composed() in tools/ctlv/ctlvgen/lib/sizes.py
is never called here.  Even after commit "tools/ctlv: Add the userspace
library" calls sizes.composed() in gen_user.py, running ctlvgen with
--validate or --mode kernel (or reconciling manifests without optional
user-source outputs) will not reject extension fills that push an
inherited op's composed request, action reply, or minimum query reply
length past MAX_INLINE_MESSAGE_LEN.

Similarly, in tools/ctlv/ctlvgen/lib/gen_kernel.py:_limits(), returning
early when family.local_ops is empty skips emitting the
CTLV_MAX_DECLARED_RECORD_DEPTH static_assert for family.fills on
descendant families that only fill inherited extension points.

> diff --git a/tools/ctlv/ctlvgen/lib/spec.py b/tools/ctlv/ctlvgen/lib/spec.py
> new file mode 100644
> index 0000000000000..7d1f2e14f70fd
> --- /dev/null
> +++ b/tools/ctlv/ctlvgen/lib/spec.py
[ ... ]
> +class Paths:
> +    """Where the specs, the registry and the meta-schemas are."""
> +
> +    def __init__(self, spec: pathlib.Path | str,
> +                 schema: pathlib.Path | str | None = None,
> +                 registry: pathlib.Path | str | None = None,
> +                 abi: pathlib.Path | str | None = None) -> None:
> +        self.spec = pathlib.Path(spec)
> +        self.spec_dir = self.spec.parent
> +        self.doc_dir = self.spec.parent.parent
> +        self.schema = pathlib.Path(schema or self.doc_dir / 'ctlv-schema.yaml')
> +        self.registry = pathlib.Path(registry or
> +                                     self.doc_dir / 'families.yaml')
> +        # The two meta-schemas ship together, so overriding one locates both.
> +        self.registry_schema = (self.schema.parent /
> +                                'ctlv-registry-schema.yaml')
> +        self.abi_dir = pathlib.Path(abi or self.doc_dir / 'abi')
> +        self.registry_abi = self.abi_dir / 'families.registry.yaml'
> +        self.generated = self.doc_dir / 'generated-files.yaml'
> +        self.generated_schema = self.doc_dir / 'generated-files-schema.yaml'
> +        self.kernel_dir = self.schema.parent.parent.parent
[ ... ]
> +def load(path: pathlib.Path | str, schema: pathlib.Path) -> FamilySpec:
> +    """Load, syntax-check and normalize one family specification."""
> +    path = pathlib.Path(path)
> +    data, lines = yamlload.load_with_lines(path)
> +    _validate_syntax(path, data, schema)
> +
> +    reader = _Reader(path, lines,
> +                     pathlib.Path(schema).parent.parent.parent / UAPI_DIR)

[Severity: Low]
Does traversing .parent on unresolved relative Path objects in
Paths.__init__() and load() misresolve kernel_dir and the UAPI directory?

In pathlib, taking .parent on a relative path strips components until it
reaches Path('.'), where .parent stays Path('.').  If ctlvgen is invoked
from Documentation/ctlv/ with "--spec specs/ctlv.yaml --validate",
self.schema is Path('ctlv-schema.yaml') and self.schema.parent.parent.parent
collapses to Path('.') instead of the kernel tree root, mislocating both
self.kernel_dir and include/uapi.  Resolving the paths first (or using
os.path.abspath) avoids this.

[ ... ]

> +    def _blob(self, where: str, args: str) -> BlobDecl:
> +        parts = _arguments(args) if args is not None else ['']
> +        direction = DIRECTIONS.get(parts[0])
> +        if direction is None:
> +            raise CtlvError(where, 'a blob starts with its direction, in, '
> +                                   'out or inout')
> +        maximum, alignment, backings = None, None, None
> +        for arg in parts[1:]:
> +            aligned = _ALIGN.fullmatch(arg)
> +            if arg.startswith('..') and maximum is None:
> +                maximum = self._size(where, arg[2:].strip(), 'blob length',
> +                                     U64_MAX)
> +            elif aligned is not None and alignment is None:
> +                alignment = self._size(where, aligned['value'],
> +                                       'blob alignment')
> +            elif backings is None and all(word in BACKINGS
> +                                          for word in arg.split()):
> +                backings = tuple(arg.split())

[Severity: Low]
Does _blob() silently accept an empty comma-separated argument such as
"blob(in, )"?

When arg is empty, arg.split() is [], so all(word in BACKINGS for word in
arg.split()) evaluates to True and sets backings = (), which is then
replaced by DEFAULT_BACKINGS via "backings or DEFAULT_BACKINGS".  Checking
that arg.split() is non-empty would reject malformed empty arguments.

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20261113155515.2510194-1-jiri@resnulli.us?part=1

  reply	other threads:[~2026-11-13 16:23 UTC|newest]

Thread overview: 24+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-11-13 15:54 [PATCH RFC v2 00/15] ctlv: Add a schema-driven TLV uAPI for character devices Jiri Pirko
2026-11-13 15:55 ` [PATCH RFC v2 01/15] ctlv: Add the schema language and generator Jiri Pirko
2026-11-13 16:23   ` sashiko-bot [this message]
2026-11-13 15:55 ` [PATCH RFC v2 02/15] ctlv: Add the UAPI message format and family API Jiri Pirko
2026-11-13 15:55 ` [PATCH RFC v2 03/15] ctlv: Add the root family specification and generated files Jiri Pirko
2026-11-13 15:55 ` [PATCH RFC v2 04/15] Documentation: ctlv: Add userspace ABI Jiri Pirko
2026-11-13 15:55 ` [PATCH RFC v2 05/15] ctlv: Prepare message input handling Jiri Pirko
2026-11-13 15:55 ` [PATCH RFC v2 06/15] ctlv: Prepare blob descriptor validation Jiri Pirko
2026-11-13 15:55 ` [PATCH RFC v2 07/15] ctlv: Prepare schema validation Jiri Pirko
2026-11-13 16:22   ` sashiko-bot
2026-11-13 15:55 ` [PATCH RFC v2 08/15] ctlv: Prepare message output handling Jiri Pirko
2026-11-13 15:55 ` [PATCH RFC v2 09/15] ctlv: Prepare VA blob handling Jiri Pirko
2026-11-13 16:13   ` sashiko-bot
2026-11-13 15:55 ` [PATCH RFC v2 10/15] ctlv: Prepare runtime introspection Jiri Pirko
2026-11-13 16:24   ` sashiko-bot
2026-11-13 15:55 ` [PATCH RFC v2 11/15] ctlv: Add operation dispatch Jiri Pirko
2026-11-13 16:29   ` sashiko-bot
2026-11-13 15:55 ` [PATCH RFC v2 12/15] ctlv: Add character device nodes Jiri Pirko
2026-11-13 15:55 ` [PATCH RFC v2 13/15] Documentation: ctlv: Describe how to write a family Jiri Pirko
2026-11-13 16:13   ` sashiko-bot
2026-11-13 15:55 ` [PATCH RFC v2 14/15] tools/ctlv: Add the userspace library Jiri Pirko
2026-11-13 16:28   ` sashiko-bot
2026-11-13 15:55 ` [PATCH RFC v2 15/15] tools/ctlv: Add ctlvctl Jiri Pirko
2026-11-13 16:19   ` sashiko-bot

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=sashiko-outbox-213188@kernel.org \
    --to=sashiko-bot@kernel.org \
    --cc=jiri@resnulli.us \
    --cc=kvm@vger.kernel.org \
    --cc=sashiko-reviews@lists.linux.dev \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox