An ACL is a security document, so the parser refuses (L4 auth / ACL)

Everywhere else in this codebase a decoder is forgiving in one specific way: an unknown key is skipped, because a newer peer legitimately sends more than the receiver understands (config_reader_t). parse_acl takes the opposite ruling on the same shape, and the reason is that leniency here does not lose a field — it inverts or widens a grant (#906, reference/05 §0x0A):

  • a dropped expires_ns turns a time-limited grant permanent;

  • a dropped flags turns a vertex-local ACE into an inherited one;

  • a type sent big-endian as u16 0x0001 truncates from DENY to ALLOW;

  • an unknown key is a restriction a newer writer meant to apply and this reader would ignore.

So the rule is: any shape encode_acl would never emit is TYPE_MISMATCH at write time, where an operator finds out, rather than a quietly weaker policy at check time, where nobody does.

What to notice

  • The example builds its ACLs by hand, deliberately. The point is precisely the shapes the typed builder cannot produce; a test that could only construct valid ACLs would be testing nothing. Each rejection is one edit away from the canonical ACE at the top, which is checked first so no refusal below is incidental.

  • Narrower than the field is the one safe leniency. Little-endian zero-extension is exact, so a two-byte access_mask names the same integer as the canonical u32 — a pre-RFC-0026 spelling stays readable. Wider is truncation, and truncation is how DENY became ALLOW.

  • An empty numeric payload is a refusal, not a zero. 0 is ALLOW for type and “never expires” for expires_ns: an absent value must not read as the permissive one.

  • A flag bit beyond kAceInherit is refused, not weakened. INHERIT_ONLY / NO_PROPAGATE would be silently mis-evaluated by the merge, so richer NFSv4 flags gate on the merge honouring them first.

  • The walk is pair-consuming. It steps one whole (NAME key, value) pair at a time, so a value can never be resynchronized onto as the next key — which a subject sent as a NAME (an accepted spelling, for EVERYONE@) previously could be (#927). An odd child count means an unpaired trailing key, and is refused.

  • A read of :acl re-encodes the parsed ACEs. It does not echo the written bytes, so what comes back can never describe a different policy from the one being enforced.

  • Nothing here is conditional — the target builds and runs under every CI leg, and every case it parses is ALLOW-typed, so the verdicts do not depend on which policy the target binds (the two profiles covers the case that does).

Source

  1/*
  2 * SPDX-License-Identifier: Apache-2.0
  3 * SPDX-FileCopyrightText: Copyright 2026 avatarsd LLC
  4 */
  5
  6/**
  7 * @file
  8 * @brief ONE CONCEPT — an ACL is a security document, so the parser REFUSES what it cannot
  9 *        read exactly.
 10 *
 11 * Everywhere else in this codebase a decoder is forgiving in one specific way: an unknown key is
 12 * skipped, because a newer peer legitimately sends more than the receiver understands
 13 * (`wire::config_reader_t`). `parse_acl` takes the OPPOSITE ruling on the same shape, and the
 14 * reason is that leniency here does not lose a field — it INVERTS or WIDENS a grant (#906,
 15 * docs/reference/05 §0x0A):
 16 *
 17 *  - a dropped `expires_ns` turns a time-limited grant permanent;
 18 *  - a dropped `flags` turns a vertex-local ACE into an inherited one;
 19 *  - a `type` sent big-endian as u16 `0x0001` truncates from DENY to ALLOW;
 20 *  - an unknown key is a restriction a newer writer meant to apply and this reader would ignore.
 21 *
 22 * So the rule is: any shape `encode_acl` would never emit is `TYPE_MISMATCH` at WRITE time,
 23 * where the operator finds out, rather than a quietly weaker policy at check time, where nobody
 24 * does. The one deliberate exception is a numeric payload NARROWER than its field —
 25 * little-endian zero-extension is exact, so it names the same integer.
 26 *
 27 * The examples below build their ACL bytes by hand, because the point is precisely the shapes
 28 * the typed builder cannot produce.
 29 *
 30 * Runs under ctest as `example_acl_parse_strict`; returns non-zero on any failed check.
 31 */
 32
 33#include <cstdint>
 34#include <cstdio>
 35#include <cstring>
 36#include <span>
 37#include <string_view>
 38#include <vector>
 39
 40#include "libtracer/byteorder.hpp"
 41#include "libtracer/security_acl.hpp"
 42#include "libtracer/tlv_emit.hpp"
 43
 44namespace {
 45
 46using tr::graph::ace_t;
 47using tr::graph::acl_right_t;
 48using tr::graph::status_t;
 49using tr::wire::opt_t;
 50using tr::wire::type_t;
 51
 52/** @brief Report expectation @p what and record a failure on @p ok. */
 53void check(bool& ok, bool cond, const char* what) {
 54    std::printf("  [%s] %s\n", cond ? "ok" : "FAIL", what);
 55    ok = ok && cond;
 56}
 57
 58/** @brief Append a `NAME key` / `VALUE <@p width little-endian bytes of @p v>` pair. */
 59void put_uint(std::vector<std::byte>& entry, std::string_view key, std::uint64_t v,
 60              std::size_t width) {
 61    tr::wire::emit_name(entry, key);
 62    std::vector<std::byte> payload(width);
 63    tr::detail::store_le(payload, v, width);
 64    tr::wire::emit_tlv(entry, type_t::VALUE, opt_t{}, payload);
 65}
 66
 67/** @brief Append a `NAME key` / `VALUE <@p text>` pair — how a subject token is spelled. */
 68void put_text(std::vector<std::byte>& entry, std::string_view key, std::string_view text) {
 69    tr::wire::emit_name(entry, key);
 70    tr::wire::emit_tlv(entry, type_t::VALUE, opt_t{},
 71                       std::as_bytes(std::span<const char>(text.data(), text.size())));
 72}
 73
 74/** @brief Wrap one hand-built ACE body as the `ACL{ ACL{…} }` collection a `:acl` write carries. */
 75std::vector<std::byte> acl_of(std::span<const std::byte> entry) {
 76    std::vector<std::byte> body;
 77    tr::wire::emit_tlv(body, type_t::ACL, opt_t{.pl = true}, entry);
 78    std::vector<std::byte> out;
 79    tr::wire::emit_tlv(out, type_t::ACL, opt_t{.pl = true}, body);
 80    return out;
 81}
 82
 83/** @brief Decode @p bytes and parse them as an ACL under the target's bound policy. */
 84tr::graph::result_t<std::vector<ace_t>> parse(std::span<const std::byte> bytes) {
 85    const auto decoded = tr::wire::decode(bytes);
 86    if (!decoded) return std::unexpected(status_t::TYPE_MISMATCH);
 87    return tr::graph::parse_acl(*decoded);
 88}
 89
 90/** @brief True iff @p r was refused as a malformed ACL. */
 91template <class T>
 92bool rejected(const tr::graph::result_t<T>& r) {
 93    return !r.has_value() && r.error() == status_t::TYPE_MISMATCH;
 94}
 95
 96/** @brief @p right as the single `access_mask` bit it is. */
 97constexpr std::uint32_t bit(acl_right_t right) { return static_cast<std::uint32_t>(right); }
 98
 99}  // namespace
100
101int main() {
102    bool ok = true;
103
104    // The canonical shape, as `encode_acl` emits it — the baseline every rejection below is one
105    // edit away from, so nothing here is refused for an incidental reason.
106    {
107        std::vector<std::byte> entry;
108        put_uint(entry, "type", 0, 1);
109        put_uint(entry, "flags", 0, 1);
110        put_text(entry, "subject", "alice");
111        put_uint(entry, "access_mask", bit(acl_right_t::READ), 4);
112        put_uint(entry, "expires_ns", 1'800'000'000'000'000'000ULL, 8);
113        const auto parsed = parse(acl_of(entry));
114        check(ok, parsed.has_value() && parsed->size() == 1, "the canonical ACE parses");
115        check(ok,
116              parsed->front().access_mask == bit(acl_right_t::READ) &&
117                  parsed->front().expires_ns != 0,
118              "…with its mask and its deadline intact");
119    }
120
121    // An UNKNOWN key. `config_reader_t` would skip the pair; this refuses the whole ACL.
122    {
123        std::vector<std::byte> entry;
124        put_uint(entry, "type", 0, 1);
125        put_text(entry, "subject", "alice");
126        put_uint(entry, "access_mask", bit(acl_right_t::READ), 4);
127        put_uint(entry, "only_on_tuesdays", 1, 1);
128        check(ok, rejected(parse(acl_of(entry))),
129              "an unknown key is rejected — skipping it would drop a restriction and widen the "
130              "grant, which is the opposite ruling to config, deliberately");
131    }
132
133    // A numeric payload WIDER than its field. `load_le` reads the low bytes, so a big-endian u16
134    // DENY (0x0001) would have read back as 0x00 — ALLOW. This is #906's motivating case.
135    {
136        std::vector<std::byte> entry;
137        tr::wire::emit_name(entry, "type");
138        const std::byte be_deny[2] = {std::byte{0x00}, std::byte{0x01}};  // DENY, big-endian u16
139        tr::wire::emit_tlv(entry, type_t::VALUE, opt_t{}, be_deny);
140        put_text(entry, "subject", "alice");
141        put_uint(entry, "access_mask", bit(acl_right_t::READ), 4);
142        check(ok, rejected(parse(acl_of(entry))),
143              "a `type` payload wider than u8 is rejected, not truncated from DENY to ALLOW");
144    }
145
146    // NARROWER is fine, and is the one place leniency is safe: zero-extension names the same
147    // integer, so a pre-RFC-0026 two-byte access_mask still reads exactly.
148    {
149        std::vector<std::byte> entry;
150        put_uint(entry, "type", 0, 1);
151        put_text(entry, "subject", "alice");
152        put_uint(entry, "access_mask", bit(acl_right_t::READ), 2);
153        const auto parsed = parse(acl_of(entry));
154        check(ok, parsed.has_value() && parsed->front().access_mask == bit(acl_right_t::READ),
155              "a NARROWER access_mask is accepted — little-endian zero-extension is exact");
156    }
157
158    // A missing required field. An absent `access_mask` is not "grants nothing"; it is an ACE
159    // whose author believed they wrote one.
160    {
161        std::vector<std::byte> entry;
162        put_uint(entry, "type", 0, 1);
163        put_text(entry, "subject", "alice");
164        check(ok, rejected(parse(acl_of(entry))), "a missing access_mask is rejected");
165    }
166
167    // An EMPTY numeric payload. It would load as 0 — `ALLOW` for `type`, "never expires" for
168    // `expires_ns` — so an absent value must not read as the permissive one.
169    {
170        std::vector<std::byte> entry;
171        put_uint(entry, "type", 0, 1);
172        put_text(entry, "subject", "alice");
173        put_uint(entry, "access_mask", bit(acl_right_t::READ), 4);
174        tr::wire::emit_name(entry, "expires_ns");
175        tr::wire::emit_tlv(entry, type_t::VALUE, opt_t{}, std::span<const std::byte>{});
176        check(ok, rejected(parse(acl_of(entry))),
177              "an empty expires_ns is rejected — absent must not read as 'never expires'");
178    }
179
180    // A flag bit the merge does not honour. INHERIT_ONLY / NO_PROPAGATE would be silently
181    // mis-evaluated, so the subset refuses them rather than weakening them.
182    {
183        std::vector<std::byte> entry;
184        put_uint(entry, "type", 0, 1);
185        put_uint(entry, "flags", 0x02, 1);  // beyond kAceInherit (0x01)
186        put_text(entry, "subject", "alice");
187        put_uint(entry, "access_mask", bit(acl_right_t::READ), 4);
188        check(ok, rejected(parse(acl_of(entry))),
189              "a flag bit beyond kAceInherit is rejected — the merge cannot honour it");
190    }
191
192    // An odd child count: a trailing key whose value the sender believes it wrote.
193    {
194        std::vector<std::byte> entry;
195        put_uint(entry, "type", 0, 1);
196        put_text(entry, "subject", "alice");
197        put_uint(entry, "access_mask", bit(acl_right_t::READ), 4);
198        tr::wire::emit_name(entry, "expires_ns");  // key, no value
199        check(ok, rejected(parse(acl_of(entry))), "an unpaired trailing key is rejected");
200    }
201
202    std::printf("one accepted shape, one safe leniency, six refusals — all at write time\n");
203    return ok ? 0 : 1;
204}

See also: security-acl module · protocol TLVs · what decode refuses · the two policy profiles · expiry.