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_nsturns a time-limited grant permanent;a dropped
flagsturns a vertex-local ACE into an inherited one;a
typesent big-endian as u160x0001truncates 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_masknames 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.
0isALLOWfortypeand “never expires” forexpires_ns: an absent value must not read as the permissive one.A flag bit beyond
kAceInheritis refused, not weakened.INHERIT_ONLY/NO_PROPAGATEwould 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 asubjectsent as aNAME(an accepted spelling, forEVERYONE@) previously could be (#927). An odd child count means an unpaired trailing key, and is refused.A read of
:aclre-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.