Skip to content

Commit 471b8b4

Browse files
committed
engine: complete the finite-set lattice, and seam off the representation
Q_PAGE_OFFSET is about to become a finite set on most architectures. Two things had to be true first, and neither was. estimate_meet handled only C_EQUALS on LK_FINSET. Both bounds and C_EXCLUDE were accepted and discarded, so a measured bound would have narrowed nothing and "the compile-time split is ruled out" -- which is an exclude -- would have been dropped by the lattice that most invites it. Every op now runs through one constraint_admits() predicate applied to each live candidate. Its switch has no default: deliberately, so a newly added op fails -Wswitch rather than being silently ignored the way C_EXCLUDE was. n_candidates is bounded by the bitmask width at compile time; the shift past a word is undefined, not merely wrong. `struct estimate` means different things per lattice -- on a finite set `lo` is a live-candidate bitmask and `hi` is unused -- so the 51 sites reading .lo and .hi were correct only for the lattice they were written against, which is not a property of the source once the same file compiles both ways. They now go through quantity_pinned/window/admits/narrowed, and check-lattice-seam holds the line. It discovers the pointer alias from its binding rather than assuming the `po` convention, so renaming cannot slip a read past. Two of those sites were already lattice-aware and would have failed silently: four rules gate on `kind != LK_INTERVAL` and would have gone inert on arm64 and riscv64, and the orchestrator's default_excluded test compared window edges, which still calls an excluded interior value possible. Asking the estimate is both representation-free and more correct. test_kasld and test_render exercise engine_sync_authoritative, which projects resolved estimates, so those single-TU builds now carry the value model they read -- after orchestrator.c, whose feature-test macros must come first.
1 parent 76eb94a commit 471b8b4

27 files changed

Lines changed: 619 additions & 69 deletions

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,7 @@ lint :
624624
@$(TEST_DIR)/check-text-floor
625625
@$(TEST_DIR)/check-text-region
626626
@$(TEST_DIR)/check-confidence-floor
627+
@$(TEST_DIR)/check-lattice-seam
627628
@$(TEST_DIR)/check-image-size
628629
@$(TEST_DIR)/check-fdt-unflatten
629630
@$(TEST_DIR)/check-ksymoff

docs/testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ and `make` halts on the first.
108108
| `check-text-floor` | no component rolls its own text-base floor — they must use the `api.h` helper |
109109
| `check-shellcheck` | shellcheck over the `extra/` helper scripts |
110110
| `check-confidence-floor` | no engine rule pins the *guaranteed* window from a guess — a sub-floor signal may shape `likely` only, outside the reviewed allowlist |
111+
| `check-lattice-seam` | quantities whose lattice varies by architecture (`Q_PAGE_OFFSET`, `Q_VA_BITS`) are read through `quantity_pinned/window/admits/narrowed`, never through `.lo` / `.hi`. `struct estimate` means different things per lattice — on a finite set `lo` is a live-candidate bitmask and `hi` is unused — so a direct read is correct only for the lattice it was written against, which is not a property of the source when the same file compiles both ways. Nothing would fail loudly: a bitmask read as an address is a small integer, so the result is a plausible wrong answer rather than a crash. The pointer alias is discovered from its binding rather than assumed to be named `po`, so renaming it cannot slip a read past |
111112
| `check-text-region` | the `KERNEL_TEXT` vs `KERNEL_IMAGE` base contract holds — only reviewed emitters may publish a `_stext` base |
112113
| `check-image-size` | the kernel image size is read only through the evidence accessors, never re-derived in a component |
113114
| `check-hash-parity` | every hashed offset-table row's key recomputes to the stored value under the shipped `kasld_fnv1a64()`, so the runtime hash and the offline generator's cannot drift apart |

src/estimate.c

Lines changed: 158 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,40 @@ static int stride_intersects_interval(unsigned long lo, unsigned long hi,
136136
return first <= hi;
137137
}
138138

139+
/* Does one concrete value satisfy this constraint?
140+
*
141+
* The finite-set lattice narrows by testing each live candidate, so it needs a
142+
* predicate that is TOTAL over the op enum. The switch carries no `default:`
143+
* deliberately: a newly added constraint op then fails -Wswitch until someone
144+
* states how a finite set answers it. That is the guard the old code lacked —
145+
* it handled C_EQUALS and let every other op through untouched, so C_EXCLUDE
146+
* and both bounds were silently discarded on a finite set.
147+
*
148+
* Interval semantics are not expressed here. An interval cannot represent an
149+
* interior hole, so LK_INTERVAL keeps its own op-by-op narrowing, including
150+
* the end-trim approximation for C_EXCLUDE. */
151+
static int constraint_admits(const struct constraint *c, unsigned long v) {
152+
switch (c->op) {
153+
case C_LOWER_BOUND:
154+
return v >= c->value;
155+
case C_UPPER_BOUND:
156+
return v <= c->value;
157+
case C_EQUALS:
158+
return v == c->value;
159+
case C_AT_LEAST_ALIGN:
160+
/* A zero alignment constrains nothing; say so rather than divide by it. */
161+
return c->value == 0 || (v % c->value) == 0;
162+
case C_EXCLUDE:
163+
/* Inclusive [value, value2]. A reversed pair is an empty range, which
164+
* excludes nothing — this test admits everything for it, as it should. */
165+
return v < c->value || v > c->value2;
166+
case C_STRIDE:
167+
/* q = value (mod value2). A zero modulus is not a residue class. */
168+
return c->value2 == 0 || (v % c->value2) == (c->value % c->value2);
169+
}
170+
return 1; /* not reached; an unhandled op is a -Wswitch build failure */
171+
}
172+
139173
/* ------------------------------------------------------------------------
140174
* Meet — narrow an estimate by one constraint.
141175
* ------------------------------------------------------------------------ */
@@ -236,22 +270,28 @@ void estimate_meet(struct estimate *e, const struct quantity_def *qd,
236270
}
237271
break;
238272

239-
case LK_FINSET:
240-
if (c->op == C_EQUALS) {
241-
/* Pin to the candidate whose value == c->value; if no candidate
242-
* matches, the intersection is empty (bottom). */
243-
unsigned long mask = 0;
244-
for (int i = 0; i < qd->n_candidates; i++)
245-
if (qd->candidates[i] == c->value)
246-
mask = 1ul << i;
247-
unsigned long narrowed = e->lo & mask;
248-
if (narrowed != e->lo) {
249-
e->lo = narrowed;
250-
e->lo_binding = c->id;
251-
}
273+
case LK_FINSET: {
274+
/* Keep every live candidate the constraint admits and drop the rest. One
275+
* uniform narrowing for all ops, so none can be ignored by omission: a
276+
* C_EQUALS naming no candidate empties the set (bottom), a bound trims the
277+
* ends, and a C_EXCLUDE removes an interior candidate exactly — the finite
278+
* set represents holes that the interval lattice has to approximate.
279+
*
280+
* `stride` stays 0 here: a C_STRIDE narrows the live set directly, leaving
281+
* no residue annotation to carry (see struct estimate). The shift is safe
282+
* unguarded because n_candidates is bounded at compile time by
283+
* KASLD_FINSET_MAX_CANDIDATES. */
284+
unsigned long mask = 0;
285+
for (int i = 0; i < qd->n_candidates; i++)
286+
if ((e->lo & (1ul << i)) && constraint_admits(c, qd->candidates[i]))
287+
mask |= 1ul << i;
288+
if (mask != e->lo) {
289+
e->lo = mask;
290+
e->lo_binding = c->id;
252291
}
253292
break;
254293
}
294+
}
255295

256296
/* Propagate the binding constraint's confidence to whichever edge it just set
257297
* (ids are monotonic from 1, so this only matches an edge this call bound;
@@ -388,6 +428,105 @@ void estimate_resolve(enum kasld_quantity q, enum kasld_confidence floor,
388428
/* ------------------------------------------------------------------------
389429
* quantity_ranges — interval-set value-access for consumers.
390430
* ------------------------------------------------------------------------ */
431+
/* ------------------------------------------------------------------------
432+
* Lattice-agnostic value access — see the contract in estimate.h.
433+
* ------------------------------------------------------------------------ */
434+
int quantity_pinned(enum kasld_quantity q, const struct estimate *e,
435+
unsigned long *out) {
436+
const struct quantity_def *qd = &quantities[q];
437+
unsigned long v = 0;
438+
switch (qd->lattice) {
439+
case LK_INTERVAL:
440+
if (e->lo != e->hi)
441+
return 0;
442+
v = e->lo;
443+
break;
444+
case LK_FINSET:
445+
if (!estimate_finset_value(qd, e, &v))
446+
return 0;
447+
break;
448+
case LK_MAXALIGN:
449+
return 0; /* an alignment is not a value */
450+
}
451+
if (out)
452+
*out = v;
453+
return 1;
454+
}
455+
456+
int quantity_window(enum kasld_quantity q, const struct estimate *e,
457+
unsigned long *lo, unsigned long *hi) {
458+
const struct quantity_def *qd = &quantities[q];
459+
unsigned long a = 0, b = 0;
460+
switch (qd->lattice) {
461+
case LK_INTERVAL:
462+
if (e->lo > e->hi)
463+
return 0; /* bottom */
464+
a = e->lo;
465+
b = e->hi;
466+
break;
467+
case LK_FINSET: {
468+
/* Candidate tables are not required to be sorted, so scan for both edges
469+
* rather than reading the first and last live bits. */
470+
int seen = 0;
471+
for (int i = 0; i < qd->n_candidates; i++) {
472+
if (!(e->lo & (1ul << i)))
473+
continue;
474+
unsigned long v = qd->candidates[i];
475+
if (!seen || v < a)
476+
a = v;
477+
if (!seen || v > b)
478+
b = v;
479+
seen = 1;
480+
}
481+
if (!seen)
482+
return 0; /* empty set — bottom */
483+
break;
484+
}
485+
case LK_MAXALIGN:
486+
return 0;
487+
}
488+
if (lo)
489+
*lo = a;
490+
if (hi)
491+
*hi = b;
492+
return 1;
493+
}
494+
495+
int quantity_admits(enum kasld_quantity q, const struct estimate *e,
496+
unsigned long v) {
497+
const struct quantity_def *qd = &quantities[q];
498+
switch (qd->lattice) {
499+
case LK_INTERVAL:
500+
if (e->lo > e->hi || v < e->lo || v > e->hi)
501+
return 0;
502+
if (e->stride && (v % e->stride) != e->stride_offset)
503+
return 0;
504+
return 1;
505+
case LK_FINSET:
506+
for (int i = 0; i < qd->n_candidates; i++)
507+
if ((e->lo & (1ul << i)) && qd->candidates[i] == v)
508+
return 1;
509+
return 0;
510+
case LK_MAXALIGN:
511+
return 0;
512+
}
513+
return 0;
514+
}
515+
516+
int quantity_narrowed(enum kasld_quantity q, const struct estimate *e) {
517+
const struct quantity_def *qd = &quantities[q];
518+
struct estimate top;
519+
qd->init_top(&top);
520+
switch (qd->lattice) {
521+
case LK_INTERVAL:
522+
return e->lo > top.lo || e->hi < top.hi || e->stride != top.stride;
523+
case LK_FINSET:
524+
case LK_MAXALIGN:
525+
return e->lo != top.lo;
526+
}
527+
return 0;
528+
}
529+
391530
int quantity_ranges(enum kasld_quantity q, const struct estimate *e,
392531
enum kasld_confidence floor, const struct constraint *cs,
393532
int n_cs, struct range *out, int out_max) {
@@ -397,6 +536,12 @@ int quantity_ranges(enum kasld_quantity q, const struct estimate *e,
397536
return 0; /* an alignment is not an address set */
398537

399538
if (qd->lattice == LK_FINSET) {
539+
/* One degenerate range per live candidate, with no read-time carving. The
540+
* interval path below carves C_EXCLUDE holes here because a single
541+
* interval cannot hold them; a bitmask can, so estimate_meet has already
542+
* applied every exclude at or above the floor `e` was resolved at. Carving
543+
* again would be a no-op at best and, if handed a different floor than `e`
544+
* was resolved at, inconsistent with the mask. */
400545
int n = 0;
401546
for (int i = 0; i < qd->n_candidates && n < out_max; i++)
402547
if (e->lo & (1ul << i)) {

src/include/kasld/estimate.h

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ struct estimate {
4848
* value satisfies (q % stride) == stride_offset in addition to lying in
4949
* [lo, hi]. Multiple C_STRIDE constraints fold via CRT in estimate_meet;
5050
* unsolvable systems push the estimate to bottom. Always 0 on
51-
* LK_MAXALIGN / LK_FINSET (the stride concept doesn't apply there). */
51+
* LK_MAXALIGN / LK_FINSET. On a finite set a C_STRIDE still narrows — it
52+
* drops every candidate outside the residue class — but it does so directly,
53+
* leaving no annotation to carry afterwards. */
5254
unsigned long stride;
5355
unsigned long stride_offset;
5456
};
@@ -98,6 +100,47 @@ int estimate_is_bottom(const struct estimate *e, const struct quantity_def *qd);
98100
int estimate_finset_value(const struct quantity_def *qd,
99101
const struct estimate *e, unsigned long *out);
100102

103+
/* ------------------------------------------------------------------------
104+
* Lattice-agnostic value access.
105+
*
106+
* `struct estimate` means different things per lattice — on LK_FINSET `lo` is
107+
* a live-candidate BITMASK and `hi` is unused — so reading `.lo` / `.hi`
108+
* directly only works if the caller already knows the quantity's lattice. That
109+
* coupling is what these exist to remove: a quantity may change lattice (or an
110+
* arch may compile it differently) without every consumer changing with it.
111+
*
112+
* `.lo_binding` and `.lo_conf` stay directly readable. They carry the same
113+
* meaning on every lattice — the constraint that last narrowed the estimate,
114+
* and its confidence — and no consumer reads the `hi_` pair, which LK_FINSET
115+
* never sets.
116+
* ------------------------------------------------------------------------ */
117+
118+
/* Narrowed to exactly one value? Writes it to *out (may be NULL) and returns 1.
119+
* An alignment (LK_MAXALIGN) is never a value, so it never pins. */
120+
int quantity_pinned(enum kasld_quantity q, const struct estimate *e,
121+
unsigned long *out);
122+
123+
/* The smallest window CONTAINING every value still admitted — for LK_FINSET
124+
* the lowest and highest live candidates, which is wider than the live set
125+
* itself. Use quantity_admits() to ask about one value; use this only for
126+
* arithmetic that genuinely wants edges. Returns 0 (leaving *lo / *hi
127+
* untouched) when nothing is admitted, which also covers the bottom estimate.
128+
* Either output may be NULL. */
129+
int quantity_window(enum kasld_quantity q, const struct estimate *e,
130+
unsigned long *lo, unsigned long *hi);
131+
132+
/* Is `v` still possible? Exact on LK_FINSET. On LK_INTERVAL it honours the
133+
* stride annotation but not interior C_EXCLUDE holes, which the lattice does
134+
* not carry — so it can answer "yes" for a value a hole has removed. That
135+
* direction is the sound one (it over-admits, never under-admits) and matches
136+
* what edge comparisons already did. */
137+
int quantity_admits(enum kasld_quantity q, const struct estimate *e,
138+
unsigned long v);
139+
140+
/* Has anything been proven about this quantity, or is it still at its honest
141+
* top? Replaces open-coded comparisons against a freshly built top. */
142+
int quantity_narrowed(enum kasld_quantity q, const struct estimate *e);
143+
101144
/* Resolve quantity q over the constraints in cs[0..n_cs), considering only
102145
* those with conf >= floor. Greedy strongest-first (conf DESC, lineage_count
103146
* DESC, id ASC); a constraint that would force bottom is skipped and

src/include/kasld/quantity.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ struct quantity_def {
6464
int n_candidates;
6565
};
6666

67+
/* Upper bound on n_candidates. An LK_FINSET estimate stores its live set as a
68+
* bitmask in a single unsigned long, so a longer list would have candidates
69+
* with no bit to live in — and `1ul << i` past the word width is undefined
70+
* rather than merely wrong. Every candidate table is checked against this at
71+
* compile time in quantities.c, so the meet loop can shift without a runtime
72+
* guard. */
73+
#define KASLD_FINSET_MAX_CANDIDATES ((int)(sizeof(unsigned long) * 8))
74+
6775
extern const struct quantity_def quantities[Q__COUNT];
6876

6977
#endif /* KASLD_QUANTITY_H */

src/orchestrator.c

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3821,8 +3821,12 @@ static void engine_sync_authoritative(const struct engine *e) {
38213821
layout.phys_kaslr_align = e->est[Q_VIRT_KASLR_ALIGN].lo;
38223822
#endif
38233823

3824-
layout.virt_page_offset_min = e->est[Q_PAGE_OFFSET].lo;
3825-
layout.virt_page_offset_max = e->est[Q_PAGE_OFFSET].hi;
3824+
/* The rendered min/max are the window CONTAINING every admitted value, which
3825+
* is what the map draws as the direct-map band's uncertainty. On a lattice
3826+
* that can hold gaps this is wider than the live set — the right direction
3827+
* for a band, which must not appear narrower than the evidence supports. */
3828+
quantity_window(Q_PAGE_OFFSET, &e->est[Q_PAGE_OFFSET],
3829+
&layout.virt_page_offset_min, &layout.virt_page_offset_max);
38263830

38273831
/* Project the resolved direct-map base onto the rendered singular field
38283832
* whenever the engine has pinned it. On coupled arches PAGE_OFFSET equals
@@ -3831,9 +3835,12 @@ static void engine_sync_authoritative(const struct engine *e) {
38313835
* it to a different boundary, and the render must follow the engine rather
38323836
* than show the compile-time seed. (The decoupled, possibly-unpinned
38333837
* RANDOMIZE_MEMORY case is handled by the block below.) */
3834-
if (e->est[Q_PAGE_OFFSET].lo == e->est[Q_PAGE_OFFSET].hi &&
3835-
e->est[Q_PAGE_OFFSET].lo != 0)
3836-
layout.virt_page_offset = e->est[Q_PAGE_OFFSET].lo;
3838+
{
3839+
unsigned long po_pin;
3840+
if (quantity_pinned(Q_PAGE_OFFSET, &e->est[Q_PAGE_OFFSET], &po_pin) &&
3841+
po_pin != 0)
3842+
layout.virt_page_offset = po_pin;
3843+
}
38373844

38383845
#if !TEXT_TRACKS_DIRECTMAP
38393846
/* On decoupled arches the direct-map base (PAGE_OFFSET) is randomized away
@@ -3850,8 +3857,10 @@ static void engine_sync_authoritative(const struct engine *e) {
38503857
* begins at the directmap base. */
38513858
{
38523859
const struct estimate *po = &e->est[Q_PAGE_OFFSET];
3853-
if (po->lo > (unsigned long)PAGE_OFFSET)
3854-
layout.virt_page_offset = po->lo;
3860+
unsigned long po_lo;
3861+
if (quantity_window(Q_PAGE_OFFSET, po, &po_lo, NULL) &&
3862+
po_lo > (unsigned long)PAGE_OFFSET)
3863+
layout.virt_page_offset = po_lo;
38553864
}
38563865

38573866
const struct estimate *pt = &e->est[Q_PHYS_IMAGE_BASE];
@@ -3954,20 +3963,27 @@ static void engine_sync_authoritative(const struct engine *e) {
39543963
* reject a real module leak -- the failure mode the union contract guards. */
39553964
{
39563965
const struct estimate *po = &e->est[Q_PAGE_OFFSET];
3957-
int pinned = (po->lo == po->hi && po->lo != 0);
3958-
int default_excluded = po->lo > (unsigned long)PAGE_OFFSET ||
3959-
po->hi < (unsigned long)PAGE_OFFSET;
3960-
if (po->lo && po->lo <= po->hi && (pinned || default_excluded)) {
3961-
unsigned long lo = MODULES_START_FOR(po->lo);
3962-
unsigned long hi = MODULES_END_FOR(po->hi);
3966+
unsigned long po_lo = 0, po_hi = 0, po_pin = 0;
3967+
int have = quantity_window(Q_PAGE_OFFSET, po, &po_lo, &po_hi);
3968+
int pinned = quantity_pinned(Q_PAGE_OFFSET, po, &po_pin) && po_pin != 0;
3969+
/* Ask the estimate whether the compile-time split is still admitted rather
3970+
* than comparing it against the window edges: on a lattice that can hold
3971+
* gaps, an edge comparison still calls an excluded interior value
3972+
* possible, and this test is what decides whether the band may move off
3973+
* that split at all. */
3974+
int default_excluded =
3975+
!quantity_admits(Q_PAGE_OFFSET, po, (unsigned long)PAGE_OFFSET);
3976+
if (have && po_lo && (pinned || default_excluded)) {
3977+
unsigned long lo = MODULES_START_FOR(po_lo);
3978+
unsigned long hi = MODULES_END_FOR(po_hi);
39633979
/* Reject a wrapped floor; do NOT clamp it to KERNEL_VIRT_VAS_START. On
39643980
* every arch that carves the module band out of vmalloc the band
39653981
* legitimately sits BELOW PAGE_OFFSET, and on those KERNEL_VIRT_VAS_START
39663982
* *is* PAGE_OFFSET -- clamping there would discard the whole band and
39673983
* reject every genuine module leak, the exact failure the union contract
39683984
* above exists to prevent. Same wrap test that
39693985
* rules/module_base_bounds.c applies to these edges. */
3970-
if (kasld_module_band_floor_sane(po->lo, lo) && hi > lo) {
3986+
if (kasld_module_band_floor_sane(po_lo, lo) && hi > lo) {
39713987
mod_union_lo = lo;
39723988
mod_union_hi = hi;
39733989
layout.modules_start = lo;

src/quantities.c

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ static const unsigned long va_bits_candidates[] = {48ul};
3030
#endif
3131
#define N_VA_BITS \
3232
((int)(sizeof(va_bits_candidates) / sizeof(va_bits_candidates[0])))
33+
/* The live set is a bitmask in one unsigned long; see the bound's definition
34+
* in quantity.h. Checked here so estimate_meet's `1ul << i` needs no runtime
35+
* guard, on the 32-bit builds too. */
36+
__extension__ _Static_assert(N_VA_BITS <= KASLD_FINSET_MAX_CANDIDATES,
37+
"VA_BITS_CANDIDATES longer than the finite-set "
38+
"bitmask can represent");
3339

3440
/* ---- honest tops ------------------------------------------------------ */
3541

0 commit comments

Comments
 (0)