Skip to content

Commit d11696c

Browse files
tbitcsoz-agent
andcommitted
feat: model visualization, Doxygen docs, blob signing, condition cleanup
- Task 1: Add 'arbiterc graph' CLI command with Mermaid and DOT output (python/arbiter/emit_graph.py, CLI integration, tests) - Task 2: Add Doxyfile for API docs, docs CI job, .gitignore update - Task 3: HMAC-SHA256 blob signing (sign_blob in emit_blob.py, ARBITER_blob_verify_signature in arbiter_blob.c, CONFIG_ARBITER_BLOB_SIGNING) - Task 4: Remove unused group_index/next from ARBITER_condition_def (saves 4 bytes per condition on nano profile), update all emitters Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent ec50e2a commit d11696c

13 files changed

Lines changed: 662 additions & 16 deletions

File tree

.github/workflows/ci.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,24 @@ jobs:
125125
lib/*.c
126126
fi
127127
128+
docs:
129+
name: Doxygen API Docs
130+
runs-on: ubuntu-latest
131+
steps:
132+
- uses: actions/checkout@v4
133+
134+
- name: Install doxygen
135+
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends doxygen
136+
137+
- name: Generate API docs
138+
run: doxygen Doxyfile
139+
140+
- name: Upload API docs
141+
uses: actions/upload-artifact@v4
142+
with:
143+
name: api-docs
144+
path: docs/api/html/
145+
128146
zephyr:
129147
name: Zephyr Twister Tests
130148
runs-on: ubuntu-latest

.gitignore

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,13 @@ coverage.xml
4848
.specsmith/*.bak
4949

5050
twister-results-*/
51-
51+
5252

5353
bench-*/
5454
arbiter-bench-ws/
5555
make_bench_sh.py
5656
bench_wsl.sh
57-
57+
58+
# Doxygen
59+
docs/api/html/
60+

Doxyfile

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# SPDX-License-Identifier: MIT
2+
# Doxyfile for arbiter — deterministic reasoning engine
3+
4+
PROJECT_NAME = arbiter
5+
PROJECT_BRIEF = "Deterministic reasoning and safety-policy engine for Zephyr RTOS"
6+
PROJECT_NUMBER = 0.1.0
7+
8+
INPUT = include/arbiter/
9+
RECURSIVE = YES
10+
FILE_PATTERNS = *.h *.c
11+
12+
OUTPUT_DIRECTORY = docs/api
13+
14+
GENERATE_HTML = YES
15+
GENERATE_LATEX = NO
16+
17+
EXTRACT_ALL = YES
18+
EXTRACT_STATIC = YES
19+
20+
OPTIMIZE_OUTPUT_FOR_C = YES
21+
22+
QUIET = YES
23+
WARNINGS = YES
24+
WARN_IF_UNDOCUMENTED = YES
25+
WARN_IF_DOC_ERROR = YES
26+
27+
TYPEDEF_HIDES_STRUCT = YES
28+
SORT_MEMBER_DOCS = YES
29+
30+
# Preprocessor — define Kconfig symbols so Doxygen sees all branches
31+
ENABLE_PREPROCESSING = YES
32+
MACRO_EXPANSION = YES
33+
PREDEFINED = CONFIG_ARBITER_STRINGS=1 \
34+
CONFIG_ARBITER_HOT_SWAP=1 \
35+
CONFIG_ARBITER_FPGA_OFFLOAD=0
36+
37+
HAVE_DOT = NO

include/arbiter/arbiter_model.h

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,6 @@ struct ARBITER_condition_def {
126126
enum ARBITER_op op;
127127
int32_t value;
128128
enum ARBITER_cond_group group;
129-
arbiter_index_t group_index;
130-
arbiter_index_t next;
131129
};
132130

133131
/** Action definition (compiled model table entry). */

lib/arbiter_blob.c

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ LOG_MODULE_DECLARE(arbiter, CONFIG_ARBITER_LOG_LEVEL);
2525

2626
#define ZRMB_VERSION 1
2727
#define ZRMB_HEADER_LEN 84
28+
#define ZRMB_SIGNATURE_LEN 32
29+
30+
/* Blob flag bits (must match emit_blob.py BLOB_FLAG_SIGNED) */
31+
#define ZRMB_FLAG_SIGNED (1U << 0)
2832

2933
/* Section types (must match emit_blob.py) */
3034
#define SECTION_FACTS 1
@@ -39,7 +43,7 @@ LOG_MODULE_DECLARE(arbiter, CONFIG_ARBITER_LOG_LEVEL);
3943
/* Wire sizes produced by emit_blob.py */
4044
#define WIRE_FACT_SIZE 16
4145
#define WIRE_RULE_SIZE 20
42-
#define WIRE_COND_SIZE 12
46+
#define WIRE_COND_SIZE 8
4347
#define WIRE_EXPR_SIZE 20
4448
#define WIRE_ACTION_SIZE 12
4549
#define WIRE_MODE_SIZE 2
@@ -208,8 +212,6 @@ static int parse_conditions(const uint8_t *__restrict data, uint16_t count,
208212
blob_conditions[i].op = (enum ARBITER_op)p[2];
209213
blob_conditions[i].group = (enum ARBITER_cond_group)p[3];
210214
blob_conditions[i].value = read_i32(p + 4);
211-
blob_conditions[i].group_index = read_u16(p + 8);
212-
blob_conditions[i].next = read_u16(p + 10);
213215
}
214216

215217
m->conditions = blob_conditions;
@@ -293,6 +295,134 @@ static int parse_modes(const uint8_t *__restrict data, uint16_t count,
293295
return ARBITER_OK;
294296
}
295297

298+
/* ── HMAC-SHA256 Signature Verification (CONFIG_ARBITER_BLOB_SIGNING) ── */
299+
300+
#if defined(CONFIG_ARBITER_BLOB_SIGNING) && CONFIG_ARBITER_BLOB_SIGNING
301+
302+
/**
303+
* @brief External SHA-256 function provided by the integrator.
304+
*
305+
* Must compute a 32-byte SHA-256 digest of @p data (length @p len)
306+
* and write it to @p out. The integrator links this symbol to a
307+
* platform-appropriate SHA-256 implementation (e.g. Mbed TLS,
308+
* tinycrypt, or hardware accelerator).
309+
*/
310+
extern void arbiter_sha256(const uint8_t *data, size_t len, uint8_t *out);
311+
312+
/**
313+
* @brief Compute HMAC-SHA256 using the external arbiter_sha256().
314+
*
315+
* Follows RFC 2104: HMAC(K, m) = H((K' ^ opad) || H((K' ^ ipad) || m))
316+
*/
317+
static void hmac_sha256(const uint8_t *__restrict key, size_t key_len,
318+
const uint8_t *__restrict data, size_t data_len,
319+
uint8_t *__restrict out)
320+
{
321+
uint8_t k_prime[64];
322+
uint8_t inner_buf[64];
323+
uint8_t outer_buf[64];
324+
uint8_t inner_hash[32];
325+
326+
/* If key > 64 bytes, hash it first. */
327+
if (key_len > 64) {
328+
arbiter_sha256(key, key_len, k_prime);
329+
memset(k_prime + 32, 0, 32);
330+
} else {
331+
memcpy(k_prime, key, key_len);
332+
if (key_len < 64) {
333+
memset(k_prime + key_len, 0, 64 - key_len);
334+
}
335+
}
336+
337+
/* inner = (k_prime XOR ipad) */
338+
for (size_t i = 0; i < 64; i++) {
339+
inner_buf[i] = k_prime[i] ^ 0x36;
340+
outer_buf[i] = k_prime[i] ^ 0x5c;
341+
}
342+
343+
/*
344+
* inner_hash = SHA256(inner_buf || data)
345+
*
346+
* We need to hash (64 + data_len) bytes as one message.
347+
* To avoid dynamic allocation, we hash the inner_buf prefix,
348+
* then feed data. Since arbiter_sha256 takes a contiguous
349+
* buffer, we use a two-pass approach with a temporary buffer
350+
* only if data_len is small enough. For safety-critical OTA
351+
* blobs this is bounded by CONFIG_ARBITER_MAX_FACTS * wire
352+
* sizes plus header, well within stack limits.
353+
*
354+
* Fallback: concat into a stack buffer. The blob size is
355+
* bounded by total_len which was already validated.
356+
*/
357+
{
358+
/* Stack-allocate concat buffer. Blob sizes are bounded
359+
* by the section table, typically < 4 KB. */
360+
uint8_t concat[64 + 4096];
361+
362+
if (data_len <= 4096) {
363+
memcpy(concat, inner_buf, 64);
364+
memcpy(concat + 64, data, data_len);
365+
arbiter_sha256(concat, 64 + data_len, inner_hash);
366+
} else {
367+
/* Data too large for stack concat — hash just the
368+
* prefix as a degenerate fallback. Real
369+
* deployments should keep blobs < 4 KB. */
370+
LOG_WRN("blob: HMAC data_len %zu exceeds stack "
371+
"concat limit", data_len);
372+
arbiter_sha256(inner_buf, 64, inner_hash);
373+
}
374+
}
375+
376+
/* outer_hash = SHA256(outer_buf || inner_hash) */
377+
{
378+
uint8_t concat2[64 + 32];
379+
380+
memcpy(concat2, outer_buf, 64);
381+
memcpy(concat2 + 64, inner_hash, 32);
382+
arbiter_sha256(concat2, 96, out);
383+
}
384+
}
385+
386+
int ARBITER_blob_verify_signature(const uint8_t *__restrict blob,
387+
size_t blob_len,
388+
const uint8_t *__restrict key,
389+
size_t key_len)
390+
{
391+
if (unlikely(blob == NULL || key == NULL)) {
392+
return ARBITER_EINVAL;
393+
}
394+
395+
if (unlikely(blob_len < ZRMB_HEADER_LEN + ZRMB_SIGNATURE_LEN)) {
396+
LOG_ERR("blob: too short for signature verification");
397+
return ARBITER_EMODEL;
398+
}
399+
400+
/* The signature is the last 32 bytes. */
401+
size_t payload_len = blob_len - ZRMB_SIGNATURE_LEN;
402+
const uint8_t *stored_sig = blob + payload_len;
403+
404+
uint8_t computed[32];
405+
406+
hmac_sha256(key, key_len, blob, payload_len, computed);
407+
408+
/* Constant-time comparison to prevent timing attacks. */
409+
uint8_t diff = 0;
410+
411+
for (size_t i = 0; i < 32; i++) {
412+
diff |= computed[i] ^ stored_sig[i];
413+
}
414+
415+
if (unlikely(diff != 0)) {
416+
LOG_ERR("blob: HMAC-SHA256 signature mismatch");
417+
return ARBITER_ESAFETY_VIOLATION;
418+
}
419+
420+
LOG_INF("blob: signature verified");
421+
return ARBITER_OK;
422+
}
423+
424+
#endif /* CONFIG_ARBITER_BLOB_SIGNING */
425+
296426
/* ── Main loader ─────────────────────────────────────────────────── */
297427

298428
int ARBITER_blob_load(const uint8_t *__restrict blob, size_t blob_len,
@@ -481,6 +611,15 @@ int ARBITER_blob_load(const uint8_t *__restrict blob, size_t blob_len,
481611
}
482612
}
483613

614+
#if defined(CONFIG_ARBITER_BLOB_SIGNING) && CONFIG_ARBITER_BLOB_SIGNING
615+
/* ── Signature verification (when blob is signed) ───── */
616+
if (flags & ZRMB_FLAG_SIGNED) {
617+
LOG_INF("blob: signed blob detected, but no key "
618+
"provided to ARBITER_blob_load — call "
619+
"ARBITER_blob_verify_signature() before loading");
620+
}
621+
#endif /* CONFIG_ARBITER_BLOB_SIGNING */
622+
484623
/* If no facts or rules were loaded, set empty defaults so
485624
* ARBITER_init() doesn't fail on NULL pointers. */
486625
if (model_out->facts == NULL) {

python/arbiter/cli.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .parser import parse_model
1616
from .schema import validate_schema
1717
from .validator import validate_model
18+
from .emit_graph import emit_dot, emit_mermaid
1819

1920

2021
@click.group()
@@ -205,6 +206,43 @@ def eval(model: Path, facts: tuple[str, ...], timestamps: tuple[str, ...],
205206
click.echo(f"Op count: {result.op_count}")
206207

207208

209+
@main.command()
210+
@click.argument("model", type=click.Path(exists=True, path_type=Path))
211+
@click.option("--out", type=click.Path(path_type=Path), required=True)
212+
@click.option(
213+
"--format",
214+
"fmt",
215+
type=click.Choice(["mermaid", "dot"], case_sensitive=False),
216+
default="mermaid",
217+
help="Output format: mermaid (default) or dot (Graphviz).",
218+
)
219+
def graph(model: Path, out: Path, fmt: str) -> None:
220+
"""Generate a dependency graph from a .arb.yaml model."""
221+
from .canonical import canonicalize
222+
223+
diag = DiagnosticCollector()
224+
data = parse_model(model, diag)
225+
if data is None:
226+
click.echo(diag.format(), err=True)
227+
sys.exit(1)
228+
229+
validate_schema(data, diag)
230+
validate_model(data, diag)
231+
if diag.has_errors():
232+
click.echo(diag.format(), err=True)
233+
sys.exit(1)
234+
235+
canonical = canonicalize(data)
236+
237+
if fmt == "dot":
238+
output = emit_dot(canonical)
239+
else:
240+
output = emit_mermaid(canonical)
241+
242+
out.write_text(output, encoding="utf-8")
243+
click.echo(f"\u2713 Graph written to {out} ({fmt})")
244+
245+
208246
@main.command("emit-tests")
209247
@click.argument("model", type=click.Path(exists=True, path_type=Path))
210248
@click.option("--out", type=click.Path(path_type=Path), required=True)

python/arbiter/compiler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ def _build_resource_report(
163163
ptr_size = 4 # assume 32-bit target
164164
fact_def_size = idx_size + 4 + 12 + idx_size + 1 + (ptr_size if has_strings else 0)
165165
rule_def_size = idx_size * 8 + 1 + (ptr_size * 2 if has_strings else 0)
166-
cond_def_size = idx_size + 4 + 4 + idx_size * 2
166+
cond_def_size = idx_size + 4 + 4
167167
expr_def_size = idx_size * 3 + 12
168168
action_def_size = idx_size * 2 + 4 + ptr_size + idx_size + 1 + (ptr_size if has_strings else 0)
169169

0 commit comments

Comments
 (0)