Repository navigation
Make the three-build comparison of decision 0010 runnable by anyone - #11
Conversation
Handbook decision 0010 step 3 requires a three-build comparison, control against unit against control again, for every component taken out of the shared tree. Nothing ran it. The capture half was /root/measure/build.sh on the build host, a scratch file in root's home directory on one machine, and the comparison half was never committed anywhere, so keel-mariadb#11 and keel-postgresql#7 were approved on a number only their author could produce. bt-layer-measure does both halves. capture builds one layer under the build lock and records its package list, file tree, symlinks, layer manifest and state paths under a directory named on the command line; compare reports what two captures differ by; attribute reports what the unit build differs from the control by that the control does not differ from itself by, and prints a block a pull request quotes whole, so a claim names the command behind it. Three things the hand-run measurement got wrong, and this does not: Paths are keyed on path<TAB>hash, not on sha256sum's "<hash> <path>". A Debian rootfs holds setuptools/_vendor/jaraco/text/Lorem ipsum.txt, which a whitespace split turns into two fields that never match themselves; that phantom cost the LAMP measurement of 2026-09-27 one file in each of its two counts (docs/traps.md, "A path with a space in it"). A path carrying a tab, a newline or a backslash is refused rather than mis-counted. A path inside the noise floor is no longer subtracted on the strength of its name. For mariadb the floor is 179 files of which 173 are ./var/lib/mysql/**, and that directory holds mysql/global_priv and mysql/user.*, so a component whose build-time job includes deleting accounts was measured blind exactly where the accounts live. share/layer-state-paths lists the paths that can hold accounts, credentials, keys or database content; their bytes are kept at capture time and compared per line for a text file and per byte offset for a binary one, in both pairs. A clock that moves in every build touches the same line or offset in both and cancels; a deleted account row leaves one the control pair never produces and is reported as real, with a three-way diff. A file that could not be read is not a file that did not change. A state path above the sample cap is recorded as not sampled and fails the run, so no report can say "0 attributable differences" about bytes it never saw. Logic in bin/layer-measure-lib, thin main in bt-layer-measure, tests in tests/measure, documented in docs/layer-measure.md. tests/coverage.sh runs the new suite: 379 of 379 lines in the library and 64 of 64 in the executable, every subcommand, every exit code and every error path, gate unchanged at 99. Closes #10
ReviewI reproduced the three recount claims independently from The design is right and the diagnosis is right. Two things in the implementation fail in the same direction as the defect this exists to fix, which is why this is a Block and not a Warning. Both are cheap to fix. CRITICAL 1. The signature cancels a real difference against a coincidental one at the same position, and prints nothing when it does
A token is a position with the content thrown away: Run against this branch, a unit build that adds an account to exit 0. The unit file contains
The bite is not hypothetical. What makes it unrecoverable is CRITICAL 2.
|
| floor | mariadb attributable | postgresql attributable |
|---|---|---|
| control vs control-again | 3 | 4 |
| control-again vs third control | 0 | 1 |
So the first real run against mariadb prints FAIL (3 attributable) for a component the handbook concludes is at zero, and the reviewer is invited to wave it through by hand. That is traps.md, "A reproducibility reference that cannot match is worse than none". Make --control-again repeatable, subtract the union, and report which floors each residue survived.
HIGH 4. Nothing enforces the precondition the whole measurement rests on
docs/layer-measure.md:36-37 and the amended 0010 both say the three captures must share SOURCE_DATE_EPOCH, one common commit and one parent "or nothing the comparison prints means anything". epoch, layer and parent are recorded and printed (bin/layer-measure-lib:574-580) and never compared. epoch none is accepted. A mismatched epoch inflates both pairs, grows the floor and still reports PASS.
Related, same line of defence: --control X --unit X --control-again X gives attributable differences: 0, verdict: PASS, exit 0. Verified. Three tags, no distinctness check.
HIGH 5. share/layer-state-paths omits credential paths for the two components already in flight
share/layer-state-paths:18-46. Absent: ./etc/mysql/**, which holds debian.cnf and the debian-sys-maint password; ./etc/postgresql/**, which is where the one real postgresql residue actually lived and was caught only because it fell outside the floor; ./root/.my.cnf; ./root/.pgpass; ./etc/redis/**; ./etc/machine-id (traps.md, "Every appliance built from core has the same machine-id").
The answer to "what happens when a future component puts state somewhere not on this list" is: if the control pair also differs there, it is subtracted by name and leaves no trace. That is the original defect, relocated. It is made worse by the report never naming what it subtracted (for mariadb, 176 paths leave the report as a single count). measure_print_set already caps long lists; printing the subtracted set would let a reviewer eyeball it for anything credential-shaped.
MEDIUM 6. Any NUL-free file is treated as text
bin/layer-measure-lib:287-298. A NUL-free, newline-free blob is one line, so its whole signature degenerates to o1 n1, which cancels against any other difference at all. Verified with three mutually different 1500-byte blobs: noise.
MEDIUM 7. A state path can be examined and counted in no total
bin/layer-measure-lib:713 reads the verdict through < <(measure_state_verdict ...) and discards the function's return value; an empty verdict increments none of the three counters, and :730 fails the run on attributable + real + unsampled only. Unreachable today because the missing-file case is pre-checked at :377-383, but it is the same shape as the pipefail bug you found. One cheap invariant closes it: assert the three counters sum to the number of paths examined.
MEDIUM 8. Captures keep long-lived copies of shadow, private keys and database bytes
bin/layer-measure-lib:207-225. A tag is never overwritten and nothing is cleaned up, by design. cp does preserve the file modes (I checked: 600 and 640 survive), so this is not a plain leak, but the directories it creates are 755 where /etc/ssl/private is 710 upstream, and --dir itself is created at the ambient umask. chmod 700 on the capture root and a retention line in the doc.
MEDIUM 9. The same shell-option divergence is still live in the three older suites
tests/measure sets pipefail and tests/layer, tests/aplinfo and tests/signature do not, while bt-layer, bt-aplinfo and bin/generate-signature all do. Out of scope for this diff, but bt-layer is what produces every capture this tool reads.
LOW 10. The documented procedure does not run as written
docs/layer-measure.md:42-45 exports seven variables and omits BT_BUILDS, which bt-layer:91 treats as fatal and which is unset in root's environment on the build host (checked). bt-layer-measure:109 hardcodes /mnt/builds/layers rather than deriving it from BT_BUILDS, so the two can also disagree silently until :124 reports no rootfs. Loud rather than silent, but docs/layer-measure.md is the reproducibility claim, and this is the first thing a second person hits.
LOW 11. shellcheck
Exits 0, so "clean" holds, but there are four info-level notes: SC2016 at bin/layer-measure-lib:160, SC2015 at tests/measure:49, SC2086 at tests/measure:443 and :445.
On the unchecked box
Leaving "a real three-build run" unchecked is the honest call, and it should gate the merge rather than follow it. Suggested gate for the first real use: run it against mariadb with four captures, publish the block, and confirm by hand that the four global_priv/user.frm verdicts say what the bytes say. Those four files are the ones this tool exists for and the ones finding 1 will mis-report.
I verified by running: the recount (both families, all pairs), the three residues, the four blind files, four false-negative cases end to end, the MEASURE_STATE_PATHS substitution, the identical-tag run, the per-floor attributable swing, tests/measure, shellcheck, and tests/coverage.sh 99. I inferred, without running: the traps.md cross-references and the severity of finding 8 on a multi-user host.
Block, on findings 1 and 2 only. Everything else can follow. This is much better than what it replaces and the recount behind it is sound; it is blocked because an instrument that reports PASS on an added account, and whose blindness can be switched off by an environment variable without the printed block showing it, fails in the exact direction it was built to correct.
…tchable Review of #11 found two ways this failed in the same direction as the defect it exists to correct. Both are closed here. The signature recorded where two files differ and threw the content away, so two unrelated changes at one line number or one byte offset annihilated, and the evidence block was suppressed for anything called noise. Four cases came back as noise, exit 0, with nothing printed: an account added to /etc/shadow at the line number the control pair also appends at; a root password set on the line the control pair rewrites for a clock reason; a binary differing at the same eight offsets with different byte values; a file 3584 bytes shorter against a control pair that varies by one. The mariadb captures on the build host show global_priv.MAD, .MAI, .frm and user.frm differing in both pairs today, so the next extraction would have got four noise lines and a count. The controls are now the model and the model carries content. At each position the controls vary at, the longest common prefix and suffix of their variants is the shape of that variation, and the unit is admitted only if its line has that shape. A mask with neither a prefix nor a suffix is what two unrelated lines have in common, which is no evidence about a third, so it never yields noise. Bytes carry no shape at all: the most that can be said of a binary is that it coincides in position, in size delta and in how much differs, which is the new verdict `overlapping`, and it fails until somebody reads the bytes and says so in --cleared. The evidence is printed for every verdict, noise included, because the one mistake this scheme can still make is the one nobody would otherwise see. The state path list was read from the environment when attribute ran, so MEASURE_STATE_PATHS pointing at a one line file turned FAIL (167 not sampled) into PASS on the real captures while the block still printed a digest. capture now copies the list into the capture, attribute reads that copy and never the variable, and it refuses unless every capture carries the same list and the digest each one recorded matches it. Also from the review: - --control-again is repeatable and the floor is the union over every control pair. One pair is not a floor: mariadb differs by 179 files in one pair and 182 in another, and the residue is 3 against the first and 0 against the second. The block prints what each single pair would have said. - attribute refuses unless the captures share layer, parent and SOURCE_DATE_EPOCH, were built with an epoch at all, and are distinct. Recording a precondition and never checking it is not recording it. - share/layer-state-paths gains ./etc/mysql/**, ./etc/postgresql/**, ./etc/redis/**, ./root/.my.cnf, ./root/.pgpass and ./etc/machine-id. The postgresql residue lived in the second of those and was caught only because it fell outside the floor. - The list is treated as a failure mode, not a list. Every subtracted path is named rather than counted, and any whose name looks like state (share/layer-state-suspect) fails the run, with two committed ways out: add it to the list, or clear it with a reason. - Text is no longer "contains no NUL": a file with no newline, or with a line over 8 KiB, is a blob and is compared as bytes. - The verdict totals must add up to the paths examined, and an empty verdict is fatal rather than uncounted. - A capture directory is created 0700, and the retention rule is in the doc. - tests/layer, tests/aplinfo and tests/signature set pipefail, which the scripts they test already did. - docs/layer-measure.md exports BT_BUILDS, and the layers directory is derived from it through config/common.cfg the way bt-layer derives it. Logic split under decision 0004: bin/layer-measure-lib keeps capture and the formats, bin/layer-compare-lib decides whether a difference is noise, bin/layer-report-lib prints the blocks and enforces the preconditions. tests/compare is the four false negatives above. Coverage: 204 of 204, 189 of 189, 230 of 230 and 70 of 70, all four at 100 percent, gate unchanged at 99.
The first real use of this, four captures of mariadb on the build host, found one thing missing from the evidence and confirmed the rest. For a binary the block listed the offsets that differ and not the bytes at them, which is not enough to settle anything by hand. It now prints a window from every capture at the first differing offset, in capture order, and that turns an unreadable list into a finding: for mysql/global_priv.MAI the value reads b9fe1a, b9fec6, b9ff64, ba0004 down the four captures, so a reader sees an Aria header clock that carried into the byte above while three control builds moved only its low bytes. measure_is_text also refuses a file carrying control bytes other than tab, newline and carriage return. Being wrong about text is safe in one direction only: a binary masked line by line can come out as noise, while a text file compared as bytes comes out overlapping and has to be cleared by hand. It stays strict rather than paranoid on purpose, because mysql/user.frm is a TYPE=VIEW definition that really is text, and its only difference between builds is one timestamp line that the mask admits. The run itself, recorded in docs/layer-measure.md: 0 attributable differences, against each single control pair and against the union of all three; 167 state paths that differ, of which 102 noise, 33 overlapping, 32 real, 0 not sampled; 6 subtracted paths, all named, none state shaped. The four the review asked about were checked against the bytes one at a time and all four say what the bytes say: global_priv.MAD and .frm overlapping at the offsets every control also moves, .MAI real because its counter carried where no control's did, and user.frm noise on a timestamp line. Coverage: 204, 216, 230 and 70 lines, all four files at 100 percent, gate unchanged at 99.
|
Both CRITICALs are closed, and the gate you proposed has been run. The full 1. Positions cancelled content. The controls are now the model and the 2. Also done: The gate. Four captures of mariadb, three controls, lock taken with a
The 32 So the FAIL is the honest state of that measurement, not a defect in the Two things from your list I did not do. MEDIUM 9 is done rather than deferred, |
Re-reviewBoth blocking findings are fixed, and one of them is fixed better than I asked for. I re-ran the four false negatives, attacked the new scheme, and checked the gate run against the bytes on the build host. Verified fixed. All four cases now fail: The CRITICAL 1. The mask constrains the two ends of a line and nothing else, so where the controls vary at random the unit may substitute a constant and be called
|
| case | mask | verdict |
|---|---|---|
ssh_host_ed25519_key.pub, random key per control, hardcoded key in the unit |
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI / root@keel |
noise |
/etc/shadow, random root hash per control, baked-in hash in the unit |
root:$y$j9T$ / :20000:0:99999:7::: |
noise |
| a planted line sharing a clock line's prefix and suffix | # written 1790475 / by build |
noise |
| the free region grown by 400 characters | token= / empty |
noise |
Those two masks are the algorithm header and the comment field, and the hash format and the date fields. They are the parts that cannot vary. The scheme reads them as evidence about the part that can.
And on the gate's own bytes: I took the real mysql/user.frm from /mnt/builds/measure/mariadb-gate/control, put timestamp=0001790574 OR 1=1; GRANT ALL ON *.* TO evil@localhost on line 10, and the tool returns noise.
The principle already in the file disposes of this. bin/layer-compare-lib:27 says bytes carry no shape, so a binary coincidence is never noise. A per-build random text field carries no more shape than a random byte field. noise should require the free region to be pinned, by the controls agreeing on its length and its character class and the unit matching both, and should fall to overlapping when it is not. That is not new policy, it is the policy already written applied to the other branch.
This is the original defect narrowed rather than closed, and it now lands on precisely the paths share/layer-state-paths exists to watch. It did not fire in the gate run, because every differing state path there was under ./var/lib/mysql/.
HIGH 2. A cleared path prints no evidence
bin/layer-report-lib:328-333: the waiver branch increments waived and continues before the verdict line is printed and before measure_evidence is called. Verified end to end: the cleared path appears zero times in the evidence section.
That contradicts the section's own header, "the evidence below is printed whatever the verdict, because the one mistake this scheme can make is to call a real difference noise", and the comment at bin/layer-compare-lib:240, "A glob waiver is only honest because the report names every path it covered". A waiver is the one place a person overrides the instrument. It is the last place to withhold the bytes.
HIGH 3. Finding 2 composed with the waiver route turns the gate green over a real defect
Verified, and this is the reason 1 and 2 are worth fixing before the waiver is granted rather than after. With a waiver naming only ./var/lib/mysql/**, which is exactly the route under discussion:
noise ./etc/shadow
noise ./etc/ssh/ssh_host_ed25519_key.pub
totals: noise 2, overlapping 0, real 0, not sampled 0, unchanged 0, cleared 1
waived in writing (1 paths, every one of them named):
./var/lib/mysql/mysql/global_priv.MAD -> Aria index header clock, ...
verdict: PASS
exit 0, and the unit in that run ships a hardcoded SSH host key. The waiver removes the last overlapping, and what is left is the column the mask can get wrong, with its evidence abbreviated or, for the waived path, absent.
MEDIUM 4. --cleared breadth is unbounded and unrecorded
bin/layer-compare-lib:243-256. A single rule *<TAB>reason matches every path, including ./etc/shadow and ./etc/ssh/ssh_host_key (verified); ./etc/* crosses slashes and matches ./etc/ssl/private/a.key. The report lists the covered paths, which is right, but capped at MEASURE_PRINT_MAX, and it never says which rule covered each path. The header at bin/layer-report-lib:250 prints the waiver file's name and no digest of its contents, so a block quoting --cleared waivers.txt cannot be checked against the file that produced it.
Worth crediting on the same mechanism: a waiver applies only to overlapping and can never clear a real. I tried it; the real survives and the run fails.
LOW 5. Doc rot
tests/measure's header comment still enumerates measure_signature, measure_state_verdict and measure_state_evidence. None of them exist now.
The gate run
Checked independently against /root/gate-block.txt (891 lines) and the captures. The tallies are right: 102 noise, 33 overlapping, 32 real. All 167 differing state paths are under ./var/lib/mysql/, and all 102 noise are .frm files, mysql.user plus the sys schema views, every one of them a TYPE=VIEW text .frm with one timestamp= line. That is coherent.
global_priv.MAD is confirmed. I pulled all four copies and read the records. Six accounts in every capture, admin at 127.0.0.1, ::1 and localhost, plus mariadb.sys, mysql and root at localhost, with identical access masks, plugins and authentication_string values. The only thing that moves is password_last_changed, 1790574121, 1790574292, 1790574458, 1790574610, climbing with capture time. overlapping is the honest verdict and the account set claim is exactly right.
global_priv.MAI is confirmed and the new window evidence earns its place. b9 fe 1a, b9 fe c6, b9 ff 64, ba 00 04 reads as a big-endian counter carrying into byte 181, which is what real at 181 and 197 means. A reviewer can settle that by eye, which was not possible before.
user.frm is correct, and the tool did not establish it. The factual claim holds: it is genuinely ASCII, longest line 4718 against the 8192 cap, only line 10 differs in every pair, and the four timestamps climb monotonically. It is also safer than the note argues, because mysql.user in 10.4+ is a view over global_priv and holds no account rows at all, so no account file is being let through here. But the mask is timestamp=0001790574 with an empty suffix, and that 21 character prefix is an accident of four builds landing inside the same thousand seconds. I checked: across a digit carry the prefix shortens, and a planted payload that keeps it is admitted. The verdict is right about these bytes and is not established by the reasoning that produced it.
The 32, and what should gate the first PASS
The waiver is the right instrument, and for a stronger reason than the one given. More captures is not merely luck on the real side: the same luck governs the noise side, because the user.frm mask's strength depends on where the builds fell relative to a digit boundary. A criterion whose answer depends on the hour the gate ran is not a criterion. Declining to teach the tool to downgrade a monotone run was also right.
But the waiver should not be granted before finding 2 is fixed, because finding 2 plus the waiver is finding 3. Fix 1 and 2, then waive, and make the waiver name:
- the exact paths, or a glob plus the number of paths it covered when it was written, so it fails instead of silently stretching when a new file appears in that directory;
- the offsets and the observed value range it covers, so the same path turning
overlappingat different offsets is not quietly included; - the capture directory and recipe commit it was justified against, so it cannot outlive its evidence;
- and it must be a file in the repository, with the report printing its digest and its rules the way it already prints the state path list's.
Operational
The evidence directory is 590 MiB and mode 700 throughout, which settles the permissions point from the last round. The disk claim in the brief does not hold: /mnt/builds is on /dev/mapper/turnkey-root, 122G, 46 percent used, 64G free, not 95 percent. Nothing needs pruning on that account, though a retention line in the doc is still worth having since a tag is never overwritten.
The pull request body reads correctly end to end: 280 lines, every section present, the gate block intact and matching /root/gate-block.txt, the coverage table matching what I measured, and the test plan boxes consistent with what I could reproduce. Nothing appears to have been lost in the rewrite.
History is clean: three commits on the tip of 19.x, all authored and committed by the same person, all passing the repository's commit-msg and pre-commit hooks when replayed, and no forbidden strings in the diff.
Verified by running: the four original cases, four new mask attacks, the planted payload on the real user.frm, the attribute-time substitution, a consistent capture-time narrowing, the waiver composition, waiver breadth and the real waiver limit, the account-set comparison on all four global_priv.MAD copies, the gate block tallies, both suites, shellcheck -S style and the coverage gate. Inferred, without running: that no credential-shaped text state path will differ in a future component's gate run, which is what finding 1 would meet.
Block, on findings 1 and 2, which are one function and one continue apart. Everything I raised last round is genuinely settled, the gate run is sound and its FAIL is honest, and I would approve this the moment noise requires a pinned free region and a cleared path prints its bytes.
Re-review found the first fix incomplete in the same direction as the defect, and it was right. The mask pinned a line's common prefix and common suffix and left the region between them free: no length, no character class, no resemblance to anything a control put there. So wherever the controls vary at random the mask degenerates to the line's fixed framing, and any value carrying that framing was admitted as noise. A hardcoded ssh_host_ed25519_key.pub behind "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI" and " root@keel", a baked-in root password hash behind "root:$y$j9T$" and ":20000:0:99999:7:::", a free region grown by 400 characters, and a SQL payload planted on the real mysql/user.frm of the gate run all came back noise. The fix is the rule already in the file applied to the other branch. This file argues that bytes carry no shape, so a binary coincidence is never noise; a per-build random text field carries no more shape than a random byte field. So noise now requires the controls to pin the free region: they agree on its length, it is no longer than MEASURE_FREE_MAX, and every character is in the class they used there, which may only be digits and the punctuation of a date. A line a control itself produced is admitted whatever its shape, because that is a value the recipe makes on its own. That also removes the dependence on when the gate ran. The user.frm mask is 21 characters of prefix only because four builds landed inside the same thousand seconds, and across a digit carry it collapses to 9. tests/compare runs that case at both lengths: the clock is noise and the payload is not, because the criterion is now the field's width and class rather than how much framing survived. A cleared path printed no verdict line and no evidence; the waiver branch returned before both. That contradicted the section's own header and it is where the bytes matter most, because a waiver is the one place a person overrides the instrument. A cleared path now prints its verdict, the rule and justification that cleared it, and its bytes, always and not subject to the abbreviation cap. The waiver itself was unbounded and unrecorded: one "*" rule matched everything including /etc/shadow, the report never said which rule covered which path, and it printed no digest. A rule is now five fields, rule, count, max-position, justified-against and reason. A rule with too little literal path is refused, a rule that covers a different number of paths than it was written against fails the run rather than stretching, and a rule justified to one offset does not cover a path differing beyond it. The block prints the file's digest and every rule with what it covered. share/layer-waivers.example is the template and it lives in the repository. tests/measure carries the composition the review built: with a waiver naming only ./var/lib/mysql/**, a unit shipping a hardcoded host key must still fail. It does; the key is overlapping and named, and the waiver covers only the two database paths it was written for. Also: tests/measure's header comment no longer lists three functions that were removed. Coverage 204, 298, 253 and 71 lines, all four files at 100 percent, gate unchanged at 99.
|
Both fixed, and the argument for the first one is yours: a per-build random 1. It also answers your point about the hour the gate ran, which I think is the 2. A cleared path prints its verdict, the rule and justification that 3. Your composition is a test. Three controls with a random host key each, 4. The waiver names the four things. Five tab separated fields: rule, 5. The gate re-ran with the stricter rule and the tallies are unchanged, 102 Corrections to my own last comment, both yours: the four original cases come I have still not granted the waiver for the 32. That is the next thing and it Coverage: 204, 298, 253 and 71 lines, all four files at 100 percent, gate |
Third reviewThe mask fix works. I re-ran all seven of my attacks and none of them is Also verified by running: a cleared path now prints its verdict, its rule, its justification and its bytes, and The carry claim holds, and for the right reason. I built the The tallies are well aimed, not vacuous. I fetched the state samples for all four captures and recomputed the verdicts myself with this branch's code. Over what I fetched, excluding the four large binaries: 102 noise, 32 overlapping, 30 real, the three missing being the ones I left out. The noise class is homogeneous, and I checked every member of it rather than sampling: all 102 are CRITICAL 1. The control-produced exemption inverts the property it protects
A unit line identical to any control's line at that position is admitted unconditionally, ahead of every pin. The base capture is the first control, so a value copied from the second or third control differs from the base, is flagged as a differing position, and is then admitted by this branch. Verified, three controls each:
The last row is the whole finding. The fresh random key is the safe behaviour and it is flagged; the fixed key is the dangerous one and it passes. End to end, with a waiver naming only exit 0, and the key in that capture is byte for byte the one in The rationale, "it is a value this recipe makes on its own", is true and does not support the conclusion. What makes a generated secret safe is that it is different every build. A unit that pins it to one previously observed instance is unsafe because it is pinned, and matching a control is evidence of pinning rather than of freshness. Deleting the early return costs nothing I can find. Where the field is pinned, the normal path already admits a coincidence on length and class. Where it is not pinned, two independent random values do not collide, so the only lines the branch admits are replays. If it is kept for a reason I have not seen, it should at least yield HIGH 2. A rule with exactly the minimum literal path clears two unrelated secrets
exit 0, with a hardcoded SSH host key and a rotating TLS private key waived under one sentence. The count check is what saves this in a single run, and it does not save it across runs: the rule stays in the repository and authorises the whole of MEDIUM 3. The shipped waiver template sets
|
…properly Two findings from the third review, and the first was one line undoing the rule it sat inside. measure_mask_admits admitted a unit line identical to any control's line at that position, ahead of every pin, on the grounds that it was a value the recipe makes on its own. The base capture is the first control, so a value copied from the second or third differs from the base, is flagged as a differing position, and was then waved through. Against three controls: a unit shipping the host key control 2 produced came out noise, the machine-id control 3 produced came out noise, a replayed root hash came out noise, and a fresh random key at the same path came out overlapping. The safe case was the one that failed, which is the property this file exists to have, inverted. The rationale was true and did not support the conclusion. What makes a generated secret safe is that it is different every build, so matching a control is evidence of pinning rather than of freshness. docs/traps.md, "Every appliance built from core has the same machine-id", is that defect already recorded here. The early return is deleted and it cost nothing: a genuinely pinned field still passes on length and class, and two independent random values do not collide, so the branch only ever admitted replays. All four cases now come out overlapping, and tests/compare carries the three replays and the fresh-key control. MEASURE_WAIVER_MIN_LITERAL counted the literal characters of a glob, and ./etc/ss* has exactly the eight it required while covering an SSH host key and a TLS private key under one sentence; ./var/lib/* has ten and covers every state tree. A rule is now an exact path, or a named directory and its subtree written DIR/**, with at least two components below ./, and the paths it actually covers must sit under one immediate child of that directory. So *, ./etc/*, ./etc/ss*, ./var/lib/*, ./etc/** and ./var/lib/my*/** are all refused, and one sentence justifies one subtree. A directory is a thing somebody chose; a character count is not. Three more from the same round: justified-against was printed and never read, which is the shape state_paths_sha256 had two rounds ago. The capture directory it names is now compared with --dir and the run fails when they differ. share/layer-waivers.example set max-position to the sample cap, which is past the end of anything that was sampled and so bounds nothing, on the line a maintainer copies. The template now carries real observed maxima, one rule per file shape, and a value at or past the cap is refused. The abbreviation cap hid 115 of the 135 non-real paths, and noise passes, so the one remaining route to a bad noise landed where no bytes were printed. Only overlapping is abbreviated now. And the correction to the plan, which was the reviewer's and is right: a waiver is consulted only for overlapping, so granting one over the 32 real paths would leave them untouched and the run still FAIL. It is more control captures and then a waiver, not one or the other. docs/layer-measure.md says so now. shellcheck -S style exits 0, which it did not before: SC2115 and SC2086 in tests/measure. Coverage 204, 316, 264 and 71 lines, all four files at 100 percent, gate unchanged at 99.
|
Both fixed, and finding 1 was the more useful of the two to have had pointed 1. The exemption is deleted. You were right that the rationale was true and 2. Glob breadth is structural. A rule is an exact path, or a named directory 3. 4. The template carries observed maxima, 16384 for the 5. Only 6. Your correction to the plan, confirmed by running it. I ran the shipped So it is more captures and then a waiver. The pull request and The gate re-ran with all of the above and the tallies are unchanged, 102 / 33 / Coverage 204, 316, 264 and 71 lines, all four files at 100 percent, gate |
Fourth reviewBoth blocks are closed and I could not reopen either. One new finding in the replacement glob rule, described below, and it is a HIGH rather than a block: nothing in the tree exercises it, and the block prints enough for a careful reader to catch it. The exemption. Deleted outright, and the author's account of why it was wrong is correct. I re-ran the four rows: replaying control 2's host key, control 3's I went looking for another route to the same shortcut and found one place that compares the unit to a control, Glob breadth. Structural now, and much better. Verified refused: The three MEDIUM and the LOW. Tallies unchanged. I recomputed every verdict from the fetched state samples with this revision's code: 102 noise, 32 overlapping, 30 real over the file set I hold, identical to what the previous revision gave over the same set. Nothing moved. HIGH 1.
|
9851c69 to
7d4ee13
Compare
Two measures of how broad a waiver rule is have now been tried and both were wrong the same way, by being easier to write than to defend. Counting the literal characters of the glob let ./etc/ss* through, which waives an SSH host key and a TLS private key under one sentence. Counting its components below ./ let ./var/lib/** through, and ./var/lib is the common parent of the six subtrees share/layer-state-paths declares separately: mysql, postgresql, redis, couchdb, mongodb, nodebb. The "one immediate child" check only sees the run in front of it, so such a rule passes whenever a single service's state differs, and a justification about MariaDB accounts clears PostgreSQL state in the next run. The count check does not catch it because the counts coincide, and that is not luck, because the same component shape produces the same number of state files. So the measure is now the project's own statement of what state is. A glob rule's directory has to be a directory the capture's own state path list declares, or below one. ./var/lib/mysql/** is allowed because the list names that directory, ./var/lib/** is refused because nothing names ./var/lib, and ./etc/ssh/** stays allowed because ./etc/ssh/ssh_host_* names it. A glob with no wildcard in its last component declares a file and not a directory, so ./etc/shadow does not license a waiver over ./etc, and ./etc/ssl/** is refused where ./etc/ssl/private/** is the declaration. ./var/cache/**, ./usr/share/** and ./etc/webmin/** are refused too, being declared nowhere. MEASURE_WAIVER_MIN_DEPTH is gone. The list is already compared byte for byte across the captures and its digest already checked, so this adds nothing new to trust. justified-against was a substring test, so a waiver justified against mariadb-gate2 was accepted for a run on mariadb-gate. Its first comma separated component is now compared for equality. docs/layer-measure.md gains a section on what this cannot see, which the review drew out of the one remaining comparison against a control, the cmp -s for the `none` verdict. State identical in every capture is invisible to a differential measurement by construction, including a secret copied out of the base control's own build tree, and the suspect net does not cover it either because such a path never differs and so is never subtracted. A PASS means nothing the change did shows up as a difference from the control; it does not mean the layer ships no secret it should not.
7d4ee13 to
d4b99c0
Compare
|
Both landed in this pull request, plus the doc line and the decision you asked HIGH 1. Your fix, taken as proposed: a glob rule's directory must be a LOW 2. The first comma separated component, compared for equality. A The doc line. The decode, and the reversal. Thank you for doing that arithmetic. I had The binary pin is the next pull request, #13, which And the merge-while-failing argument is in the body, in your words rather Coverage 204, 322, 264 and 71 lines, all four at 100 percent, gate unchanged at |
Fifth reviewThe licensing rule is the right one and is correctly built. I could not find a hole in it. The hole is now in the thing it licenses from, which is what you would expect and is the one finding below. Verified as reported. Refused: Also verified: HIGH 1. The list now decides two things and is still anchored to nothingThe licensing rule makes So the way to license a rule is to declare its directory. Four captures taken against a list with and the same rule, with the same MariaDB sentence, on a postgresql shaped run: exit 0 both times. What changed with this commit is that widening the list used to be benign. It only ever increased what was compared by content, which is the safe direction. It now also widens the waiver vocabulary, so the same knob has acquired a second effect pointing the other way. The fix is the last unfinished piece of a finding from two rounds ago, when Not a block: it takes an operator running captures against a list that is not the committed one, the mismatch is in principle visible in the digest, and the fix is one comparison. But it is the same shape as the defect this tool exists to correct, so I would not leave it for later. MEDIUM 2. Three of the new guards are not pinned by a testI mutated each of the six new branches and ran The last two are redundant rather than untested: remove them and the licensing loop refuses the same inputs anyway, so they are defence in depth and the mutation surviving is the correct result. The slash guard is the one with no test behind it, covering a malformed list entry with no Worth saying because of what it means for the number: see below. LOW 3. A declaration with a wildcard in a non-final component licenses nothing
The coverage questionTwo separate things, and the answer differs between them. Is the array version equivalent? Yes, and by measurement rather than by reading: I reimplemented the licensing loop in the Is the restored 100 percent real? Yes as line coverage, and it is not a reattribution artefact: the lines execute under the tests, and three of the six new branches fail the suite when removed, which is coverage doing work. But 100 percent line coverage is not 100 percent branch behaviour, and the mutation test above says exactly which three lines a test would not miss. Two of those three are redundant guards, so the honest summary is that one new line, the slash guard, is executed without being tested. That is a small and specific gap, and saying which line it is seems better than either claiming the number means more than it does or discounting it. Changing the loop and saying so, rather than quietly restoring the figure, was the right way to handle it. The "What this cannot see" sectionStrong enough, and written for the reader you described. It names the case that matters rather than gesturing at it, a secret copied out of the base control's own build tree; it explains why the suspect net does not cover it, that a path which does not differ was never subtracted; and it gives the two sentences somebody hunting for a larger claim cannot get round, that a PASS means nothing the change did shows up as a difference from the control and does not mean the layer ships no secret it should not. Pointing at a single image instrument and at the machine-id trap entry is the right place to send them next. One addition. The person who over-reads a PASS is reading the block, not this section. I would repeat the second of those two sentences in the part of the doc about what a pull request quotes, so it travels with the thing being quoted. History is clean: six commits on the tip of Verified by running: 14 rule shapes against the committed list and against a widened one, the declared-directory derivation, the path boundary case, the end to end list widening and the cross component justification transfer, the suspect net's silence on it, the justification prefix case, six mutations of the new branches, a 45 case equivalence test between the array and process substitution forms, an independent recount of the gate verdicts, five suites, Warning. One HIGH, which is one comparison. Everything from the previous four rounds is closed and I could not reopen any of it. |
The licensing rule of the previous commit made state-paths.txt load bearing twice: it decides which bytes a capture keeps, and, since a waiver rule's directory has to be one the list declares, it now also decides which waivers are legal. measure_preconditions checked that every capture carried a byte identical list whose recorded digest matched, which says the operator was consistent with themselves and nothing more. So four captures taken against a list with ./var/lib/** added agree with each other, the block reports that they agree, a ./var/lib/** waiver is licensed, and one sentence about MariaDB accounts clears PostgreSQL state on the next run. share/layer-state-suspect cannot catch it and reports 0 subtracted but state shaped throughout, because widening the list makes more paths state paths, so nothing new is subtracted. What changed is that widening the list used to be harmless: it only ever increased what was compared by content. The same knob now points both ways. attribute compares the capture's copy with the committed share/layer-state-paths and refuses on mismatch. A run against another list declares a reason with --state-paths-override, which the block prints as OVERRIDE, and the block prints both digests in any case, because one digest with nothing to check it against is the shape of the finding two rounds ago where state_paths_sha256 was recorded and never read. Refusal rather than a printed warning, for that same reason: every other precondition here refuses, and a warning relies on somebody noticing. The anchor has no environment override. MEASURE_STATE_PATHS is a capture time knob and attribute ignores it entirely, including as a way of moving the anchor, so the property that pointing it at a narrower file changes nothing after the fact is unchanged and is still tested. tests/measure now takes its captures against the committed list rather than a fixture list, which is the real configuration and removes a difference between the suite and a real run. Two smaller things from the same review. A declaration with a wildcard in a non-final component, ./home/**/.ssh/**, yields the declared directory ./home/**/.ssh, which is compared literally and which no wildcard-free rule can sit below, so it licenses nothing. That is intended and now says so in the doc: it fails closed, the route for those paths is an exact path waiver, and matching a glob against a glob here would put back the breadth ambiguity the rule exists to remove. docs/layer-measure.md repeats, in the section about what a pull request quotes, that a PASS does not mean the layer ships no secret it should not, because the person over-reading a PASS is reading the block and not the section that explains it. And the slash guard in measure_state_dirs, which skips a malformed list entry with no / in it, was executing without a test behind it. It has one now, state-dirs-noslash. COVERAGE.md records which of the six branches added last commit a mutation test kills and which survive because they are redundant, since 100 percent line coverage with an untested line in it claims more than it has.
|
Landed, and refusal rather than a printed warning. The argument for refusing. Every other precondition here refuses: epoch,
The anchor has no environment override, which matters for the property you I kept both things you flagged: LOW 3, intended. LOW 4. The "does not mean this layer ships no secret it should not" sentence The coverage gap. Tested, not excused: Merge order. Keel-Linux/handbook#8 says 0010's step 3 names Coverage 206, 330, 270 and 77 lines, all four at 100 percent, gate unchanged at |
Sixth review, scoped to the deltaThe anchor holds. I attacked the three things you named and could not get past any of them; two small things below, neither blocking. The two credited details survive verbatim. 1. The overrideIt cannot be set without appearing in the block, and it cannot be injected. The two digests differ on their face, so the On durability: the reason lives only in the block. Nothing is written into the capture, so a later reader of the capture directory alone cannot tell an override was ever used. That is the safe direction, because re-running 2. The anchor and the environmentVerified genuinely immovable. 3. The suite against the committed listThe content coupling is gone and was removed the right way: A count coupling remains. So adding a state path that matches something in the fixture rootfs breaks the suite. It fails loudly and names the assertion and both numbers, so it is self-announcing rather than silent, and the fix is obvious to whoever adds the path. Asserting which paths were sampled rather than how many would make it immune. Low, and I would not hold the merge for it. 4. Refusal rather than a warningThe reasoning is right and I would have argued the same. The specific point that carries it is that the defect being closed is a field recorded and never read, so a remedy whose whole weight rests on a reader noticing a second line reproduces the defect in a new place rather than closing it. Consistency with the other preconditions is the weaker half of the argument but also true: LOW. The
|
The OVERRIDE line asserted "the captures were taken against a list that is not the committed one" whenever --state-paths-override was given, whether or not the anchor check had failed. On captures that carry the committed list the block then printed two identical digests and, directly beneath them, a line saying they differ. It erred in the safe direction, but the block exists so a reader can check each claim against the evidence printed next to it. attribute now records that the anchor check failed, and the report prints OVERRIDE only then. A reason given on captures that do carry the committed list is still printed, on an "override" line saying it was declared and not needed, so nothing the operator passed is dropped from the block. Coverage: layer-report-lib 273 of 273, bt-layer-measure 78 of 78. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
tests/measure asserted that exactly four fixture files matched the committed state path list. Since the suite takes its captures against the committed list, adding a state path that happens to match a fixture file, ./etc/keel/** or ./var/lib/dpkg/** for instance, broke the suite with a count mismatch that said nothing about sampling. It now asserts the four paths that must be sampled and two that must not, so the list can grow without touching the suite. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
0010 and the trap entry described the first version of the command: one --control-again in the worked example, and a floor subtracted per line and per byte offset in which a clock "touches the same line or offset in both pairs and cancels". Review of Keel-Linux/buildtasks#11 showed that scheme is the defect, not the fix: position cancels against position with the content thrown away, so an account added on the line a clock moves on came back as noise. The command no longer works that way, and a note that makes its block the standard has to describe the block it actually prints. - The worked example takes three controls and passes every one, since the floor is the union over every pair. - The preconditions attribute enforces are named, including the anchor to the committed state path list and --state-paths-override. - The block's contents are listed as they now are, and it carries the sentence that a PASS does not mean the layer ships no secret it should not. - The verdicts are described as they are: noise only where the controls pin the varying field, overlapping for anything else at a varying position and for every binary, real where the controls do not vary, and waivers that clear only overlapping. - The first real run is recorded: 0 attributable, FAIL on 32 real state paths that are the Aria header wall clock, open in Keel-Linux/buildtasks#13. The trap entry's Fix now says not to cancel by position, instead of prescribing it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
|
Both LOW from the sixth review fixed in 0a0cea1 and fb6c1ed: |
Make the three-build comparison of decision 0010 runnable by anyone
0010 and the trap entry described the first version of the command: one --control-again in the worked example, and a floor subtracted per line and per byte offset in which a clock "touches the same line or offset in both pairs and cancels". Review of Keel-Linux/buildtasks#11 showed that scheme is the defect, not the fix: position cancels against position with the content thrown away, so an account added on the line a clock moves on came back as noise. The command no longer works that way, and a note that makes its block the standard has to describe the block it actually prints. - The worked example takes three controls and passes every one, since the floor is the union over every pair. - The preconditions attribute enforces are named, including the anchor to the committed state path list and --state-paths-override. - The block's contents are listed as they now are, and it carries the sentence that a PASS does not mean the layer ships no secret it should not. - The verdicts are described as they are: noise only where the controls pin the varying field, overlapping for anything else at a varying position and for every binary, real where the controls do not vary, and waivers that clear only overlapping. - The first real run is recorded: 0 attributable, FAIL on 32 real state paths that are the Aria header wall clock, open in Keel-Linux/buildtasks#13. The trap entry's Fix now says not to cancel by position, instead of prescribing it.
Closes #10. Paired with Keel-Linux/handbook#8, which amends decision 0010 to name
this command and reports what a recount of the two merged extractions found
with it.
Handbook decision 0010 accepts the component split on one condition, stated in
step 3 of its order of work: components move one at a time, "re-running this
three-build comparison for each, including the control-vs-control run".
Nothing in this organization ran it. The capture half was
/root/measure/build.shon the build host, a scratch file in root's homedirectory on one machine; the comparison half, the part that turns captures
into the number a pull request quotes, was ad hoc shell that was never
committed at all. Keel-Linux/keel-mariadb#11 and Keel-Linux/keel-postgresql#7
were both approved on a count only their author could produce.
--diris required by every subcommand and defaults to nothing, so thedirectory holding the measurement is part of the command and a second person
re-runs the same command against the same directory. No state in a home
directory on one machine.
capturetakes/run/lock/keel-build.lockwith abounded wait. A tag is never overwritten. The full procedure is in
docs/layer-measure.md.Why this repository and not
.github.github/liblends shell to the organization's reusable workflows; this is notthat. The capture half runs
bt-layerand reads the rootfs it leaves in$BT_BUILDS/layers, so it is written againstbin/layer-lib's own vocabularyof layers, parents, manifests and units. The build host already has this
repository at
/turnkey/buildtasks-keel, which is where the three buildshappen, and
.githubis not checked out there at all. This repository alsoalready has the gate the work needs,
tests/coverage.shunder kcov at a 99percent floor, and decision 0010 step 1, making
bt-layerunit-aware, landedhere in #8.
What the hand-run measurement got wrong
It counted paths it could not key on. The capture wrote
sha256sum'sdefault
<hash>plus two spaces plus<path>and the comparison split onwhitespace. A Debian rootfs holds
setuptools/_vendor/jaraco/text/Lorem ipsum.txt, which becomes two fields andnever matches itself, so the LAMP measurement of 2026-09-27 reported 199 and
183 differing files where the true counts were 198 and 182 (
docs/traps.md,"A path with a space in it"). Every record here is
path<TAB>...with the pathin field 1, and a path carrying a tab, a newline or a backslash is refused at
capture time rather than mis-counted.
It was blind where it mattered most. For mariadb 173 of the 179 files in
the floor are
./var/lib/mysql/**, which holdsmysql/global_privandmysql/user.*, and the component's build-time job includes deleting accounts.A path is put inside the floor by its name; whether its difference is noise is
a question about its bytes.
It treated a file it could not read as a file that did not change.
The rework after review
Review of the first push found two ways this failed in the same direction as
the defect it exists to correct. Both are closed.
The signature compared positions and threw the content away
o<n>/n<n>,@<n>and a barelenare positions. Two unrelated changes atone line number or one byte offset annihilated, and the evidence block was
suppressed for anything called noise, so the one mistake the scheme could make
was the one nobody could see. Four cases came back
noise, exit 0, withnothing printed:
/etc/shadowat the line number the control pair alsoappends at,
The controls are now the model and the model carries content. At each position
the controls vary at, the longest common prefix and suffix of their variants is
the shape of that variation, and the unit is admitted only if its line has
that shape.
# written 1790475089against# written 1790475323gives# written ..., which# written 1790475511fits androot:$y$hash:...doesnot. A mask with neither a prefix nor a suffix is what two unrelated lines have
in common, which is no evidence about a third, so it never yields noise. Bytes
carry no shape at all, so a binary is never noise: the most that can be said is
that it coincides in position, in size delta and in how much differs, which is
the new verdict
overlapping, and it fails until somebody reads the bytes andsays so in
--cleared. The evidence is printed for every verdict,noiseincluded.
noiseoverlappingrealnot sampledunchangedtests/compareis that list of four, one case each. They come outoverlapping,real,overlapping,real.The blindness could be switched off at attribute time
MEASURE_STATE_PATHSwas read whenattributeran, so a one-line substitutedlist turned
FAIL (167 not sampled)intoPASSon the real captures while theblock still printed a digest.
capturenow copies the list into the capture,attributereads that copy and never the variable, and it refuses unless everycapture carries the same list and the digest each one recorded matches it.
The rest of the review
--control-againis repeatable and the floor is the union over everycontrol pair. One pair is not a floor: mariadb differs by 179 files in one
pair and 182 in another, and the residue is 3 against the first and 0 against
the second. The block prints what each single pair would have said.
attributerefuses unless the captures sharelayer,parentandSOURCE_DATE_EPOCH, were built with an epoch at all,carry the same state path list, and are distinct.
--control X --unit Xisrefused.
share/layer-state-pathsgains./etc/mysql/**,./etc/postgresql/**,./etc/redis/**,./root/.my.cnf,./root/.pgpass,./root/.netrc,./etc/machine-idand./var/lib/dbus/machine-id. The postgresql residuelived in the second of those and was caught only because it fell outside the
floor.
is named rather than counted, and any whose name looks like state
(
share/layer-state-suspect) fails the run, with two committed ways out: addit to the list, or clear it with a reason.
8 KiB, or a control byte other than tab, newline and carriage return is a
blob and is compared as bytes.
is fatal rather than uncounted.
0700; retention is in the doc.tests/layer,tests/aplinfoandtests/signaturesetpipefail, whichthe scripts they test already did. One assertion changed with it:
tests/signaturepipedgenerate-signature --helpintogrepand usageexits non-zero.
docs/layer-measure.mdexportsBT_BUILDS, and the layers directory isderived from it through
config/common.cfgthe waybt-layerderives it.overlappingwithoutmaterialising the offsets:
ib_logfile0is 100 MiB.The second rework, after re-review
The re-review found the first fix incomplete in the same direction, and it was
right twice.
The mask pinned only the two ends of a line
It constrained the common prefix and the common suffix and left the region
between them free: no length, no character class, no resemblance to anything a
control put there. So wherever the controls vary at random the mask degenerates
to the line's fixed framing, and any value carrying that framing was admitted
as
noise:ssh_host_ed25519_key.pubssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI/root@keelnoiseroot:$y$j9T$/:20000:0:99999:7:::noisetoken=/ emptynoisetimestamp=0001790574 OR 1=1; GRANT ALL ON *.* TO evil@localhost, on the realmysql/user.frmof the gate runtimestamp=0001790574/ emptynoiseThe fix is the rule already written in the file applied to the other branch.
bin/layer-compare-libargues that bytes carry no shape, so a binarycoincidence is never
noise. A per-build random text field carries no moreshape than a random byte field. So
noisenow requires the controls to pinthe free region: they must agree on its length, it must be no longer than
MEASURE_FREE_MAX(24), and every character must be in the class they usedthere, which may only be digits and the punctuation of a date. A line a
control itself produced is admitted whatever its shape, because that is a
value the recipe makes on its own. All five cases above now fail.
This also removes the dependence you named on when the gate ran. The
user.frmmask is 21 characters of prefix only because four builds landedinside the same thousand seconds, and across a digit carry it collapses to 9.
tests/compareruns exactly that: at either prefix length the clock isnoiseand the payload is not, because the criterion is now the field's widthand class rather than how much framing happened to survive.
A cleared path printed no verdict line and no evidence
The waiver branch returned before both. A cleared path now prints its verdict,
the rule and justification that cleared it, and its bytes, always and not
subject to the abbreviation cap. It is the one place a person overrides the
instrument, so it is the last place to withhold the evidence.
The waiver was unbounded and unrecorded
A rule is now five tab separated fields, and
share/layer-waivers.exampleisthe template, committed:
rule*matches./etc/shadowcountmax-positionjustified-againstreasonThe block prints the file's digest, every rule, how many paths each covered
against how many it was written for, and every cleared path with its bytes.
Your composition, as a regression test
tests/measurebuilds it: three controls with a random host key each, a unitwith a hardcoded one, and a waiver naming only
./var/lib/mysql/**. It fails,the key is named as
overlapping, and the waiver covers only the two databasepaths it was written for:
The third rework, after the third review
Two findings, one of them a line I had written to be helpful that inverted the
property the tool exists to have.
The control-produced exemption
measure_mask_admitsadmitted a unit line identical to any control's lineat that position, ahead of every pin. The base capture is the first control, so
a value copied from the second or third differs from the base, is flagged as a
differing position, and was then waved through:
noiseoverlapping/etc/machine-idcontrol 3 producednoiseoverlapping/etc/shadowreplaying control 2's root hashnoiseoverlappingoverlappingoverlappingThe last row was the finding: the safe case was the one that failed. My
rationale, "a value this recipe makes on its own", was true and did not support
the conclusion. What makes a generated secret safe is that it is different
every build, so matching a control is evidence of pinning, not of freshness,
and
docs/traps.mdalready records the instance: "Every appliance built fromcorehas the same machine-id".The early return is deleted. It cost nothing: a genuinely pinned field still
passes on length and class, and two independent random values do not collide,
so the branch only ever admitted replays.
tests/comparecarries the threereplays and the fresh-key control.
Glob breadth is not a character count
./etc/ss*had exactly the eight literal characters the old check wanted, andcovered an SSH host key and a TLS private key under one sentence.
./var/lib/*had ten and covered every state tree.
A rule is now an exact path, or a named directory and its subtree written
DIR/**with at least two components below./, and the paths it actuallycovers must sit under one immediate child of that directory. Verified
against the current tree:
A directory is a thing somebody chose; a character count is not. One sentence
now justifies one subtree.
The three MEDIUM
justified-againstis enforced. The capture directory it names iscompared with
--dirand the run fails when they differ. On the realcaptures, rewriting the template's directory gives
THIS RULE NAMES OTHER CAPTURESand a FAIL.carries real observed maxima, 16384 for the
.MADand 76 for the.frm, onerule per file shape, and a
max-positionat or past the sample cap is nowrefused outright.
overlappingis abbreviated.noisepasses, so the one remainingroute to a bad
noisewas landing where no bytes were printed. In the gaterun the block now prints bytes for 145 of 167 paths, up from 52, with 22
abbreviated and all of them
overlapping.shellcheck -S styleexits 0, which it did not when I checked that box:SC2115 and SC2086 in
tests/measure.The waiver cannot reach PASS, and the correction is yours
Confirmed by running the shipped template against the real captures. It clears
the two paths it names, with rule, justification and bytes printed, and the run
is still FAIL:
So it is more control captures and then a waiver, not one or the other.
docs/layer-measure.mdsays so now, and the earlier claim that either would dois removed.
The fourth rework, and why this merges with its first subject failing
The waiver vocabulary is now the state list's vocabulary
Two measures of glob breadth had been tried and both were wrong the same way.
Counting literal characters let
./etc/ss*through. Counting components below./let./var/lib/**through, and./var/libis the common parent of the sixsubtrees
share/layer-state-pathsdeclares separately, so one sentenceabout MariaDB accounts could clear PostgreSQL state in a later run whose count
coincided. The counts coinciding is not luck: the same component shape produces
the same number of state files.
A glob rule's directory must now be a directory the capture's own
state-paths.txtdeclares, or below one../var/lib/mysql/**allowed,./var/lib/**refused,./etc/ssh/**still allowed because./etc/ssh/ssh_host_*names that directory. A glob with no wildcard in its lastcomponent declares a file and no directory, so
./etc/shadowdoes not license./etc/**, and./etc/ssl/**is refused where./etc/ssl/private/**is thedeclaration.
./var/cache/**,./usr/share/**and./etc/webmin/**arerefused too.
MEASURE_WAIVER_MIN_DEPTHis gone. The list is already comparedbyte for byte across the captures with its digest checked, so this adds nothing
new to trust.
justified-againstwas a substring test and acceptedmariadb-gate2for a runon
mariadb-gate; its first comma separated component is compared for equalitynow.
What this cannot see, now written down
docs/layer-measure.mdgains a section on it. State identical in every captureis invisible to a differential measurement by construction, including a secret
copied out of the base control's own build tree, and
share/layer-state-suspectdoes not cover that either, because such a pathnever differs and so is never subtracted. A PASS means "nothing the change did
shows up as a difference from the control". It does not mean the layer ships no
secret it should not.
The 32 are an open item, not a waiver, and more captures cannot clear them
#13. The field was decoded from the captures: bytes 180 to
183 of the Aria index header are a four byte big endian wall clock Unix
second,
SOURCE_DATE_EPOCHdoes not touch it, and1790574106902134 / 10^6is exactly
user.frm's microsecond timestamp in the same directory. The unitcrossed into byte 181 where three controls had not.
Byte 181 carries every 18.2 hours and builds are 160 s apart, so an expected
single crossing needs on the order of 410 consecutive control builds; byte
180 carries every 194 days. For any finite control set there is always a higher
byte that did not carry, and the spread required grows by 256 per byte. So
more control captures cannot fix this and the build host should not be asked
for them. That reverses what an earlier revision of this description said.
The fix is the pin this tool already has for text, applied to a binary field,
and it is not in this pull request: issue #13 carries the four conditions.
Landing a new inference rule in the same change as the instrument, in order to
make the instrument's first subject pass, is how the defect this pull request
exists to fix came about. So the 32 are published as they are, recorded against
#13, with no waiver over them, which the tool refuses anyway because a waiver
only ever clears
overlapping.Merging the instrument while its first subject fails
That is deliberate and it is the point. The alternative is to hold the tool
until mariadb passes, which means the first thing anybody does with it is tune
it until it agrees with a conclusion already reached. That is precisely how the
measurement this replaces came to be trusted: a number was produced, it said
zero, and nothing that could have contradicted it was ever run. A FAIL that
names 32 paths, decodes the field they differ in and points at the issue that
would resolve it is worth more than a PASS arranged by adjusting the
instrument.
The fifth rework: the list is anchored to the repository
The licensing rule made
state-paths.txtload bearing twice. It decides whichbytes a capture keeps, and, since a waiver rule's directory has to be one the
list declares, it now also decides which waivers are legal. The preconditions
checked that every capture carried a byte identical list whose recorded digest
matched, which says the operator was consistent with themselves and nothing
more: four captures taken against a list with
./var/lib/**added agree witheach other, the block says they agree, the waiver is licensed, and one sentence
about MariaDB accounts clears PostgreSQL state.
share/layer-state-suspectcannot catch it and reports
0 subtracted but state shapedthroughout, becausewidening the list makes more paths state paths so nothing new is subtracted.
What changed is that widening the list used to be harmless. It only ever
increased what was compared by content. The same knob now points both ways.
attributecompares the capture's copy with the committedshare/layer-state-pathsand refuses on mismatch. A run against anotherlist declares a reason with
--state-paths-override, printed in the block asOVERRIDE, and both digests are printed in any case.Refusal rather than a printed warning, which was the alternative offered.
Every other precondition here refuses, and the defect being closed is a field
that was recorded and never read; a warning relies on somebody noticing, which
is the failure mode itself. The digests are printed regardless, so the weaker
remedy is there too.
The anchor has no environment override.
MEASURE_STATE_PATHSis a capturetime knob and
attributeignores it entirely, including as a way of moving theanchor, so the property that pointing it at a narrower file changes nothing
after the fact survives and is still tested.
tests/measurenow takes itscaptures against the committed list rather than a fixture list, which removes a
difference between the suite and a real run.
The two LOW
./home/**/.ssh/**declares./home/**/.ssh, compared literally, which nowildcard-free rule can sit below, so that declaration licenses nothing. That is
intended and the doc says so: it fails closed, an exact-path waiver is the route
for those paths, and matching a glob against a glob here would put back the
breadth ambiguity the rule exists to remove.
The sentence "a PASS does not mean the layer ships no secret it should not" is
now repeated in the section about what a pull request quotes, because the person
over-reading a PASS is reading the block.
What the coverage number does and does not say
Recorded in
COVERAGE.mdrather than left as a bare 100 percent. Of the sixbranches added with the licensing rule, three fail the suite when removed: the
wildcard-in-last-component guard in
measure_state_dirs, the equality clause inmeasure_dir_at_or_below, and loosening that function's path boundary to aprefix match. Two survive because they are redundant, the licensing loop
refusing the same inputs without them, so they are defence in depth and a
surviving mutant is the right result. The sixth, the
*/*guard that skips amalformed entry with no slash, was executing untested and now has
state-dirs-noslashbehind it.The two LOW from the sixth review
The
OVERRIDEline is printed only when the anchor check actually failed. Areason given on captures that do carry the committed list is printed on an
overrideline as declared and not needed, so the block never asserts amismatch its own two digests contradict. And
tests/measureasserts whichfixture paths were sampled rather than how many, so adding a state path to the
committed list no longer breaks the suite; checked by adding
./etc/keel/**and
./var/lib/dpkg/**.The gate block below predates the fifth rework and these two; neither changes a
verdict, and the header of a run today also prints the
committeddigest line.The merge gate: mariadb, four captures
Run on the build host,
SOURCE_DATE_EPOCH=1700000000,coreas parent, threecontrols and one unit, lock taken with a bounded wait, nothing else built and
nothing published. Captures in
/mnt/builds/measure/mariadb-gate, 590 MiB.Abridged: the full 676 lines are
/root/gate-block.txton the build host.Zero attributable, which is the conclusion decision 0010 reached, now
against the union of three control pairs and also against each pair alone. What
is new is the 167 state paths, which that measurement subtracted by name
without opening.
The four, confirmed against the bytes
global_priv.MADoverlappingcontrolvscontrol-thirddiffers at. The strings showpassword_last_changedmoving 1790574121 to 1790574610, a clock, and the account set is identical:adminat127.0.0.1,::1,localhost. So it is a clock, and bytes cannot prove that, which is why it must be cleared rather than assumed.global_priv.MAIrealglobal_priv.frmoverlappingcontrolvscontrol-third; no string differs..frmcreate and update timestamps.user.frmnoisemysql.useris aTYPE=VIEWoverglobal_priv, the.frmis ASCII with newlines, and its only difference is one line,timestamp=0001790574106902134against...596687502, which the mask the three controls establish there admits.Why 32 came out
realAlmost all of them are
*.MAI. The Aria index header carries a Unix timestamp,and in 30 of the 32 it increases strictly in capture order. Three control
builds moved only its low bytes; the unit build, four minutes later, carried
into the byte above. The evidence now prints the bytes from every capture at
the first differing offset, in capture order, which is what makes that readable
rather than a list of offsets:
The other 33, the
overlappingones, are almost all*.frm, whose timestampssit at fixed offsets every build moves together.
So this FAIL is the honest state of the measurement and not a defect in the
component. Neither a waiver nor more captures clears it: a waiver is consulted
only for
overlappingand these arereal, and the field is a wall clocksecond whose higher bytes carry on scales of 18.2 hours and 194 days, so no
finite set of controls spans them. The resolution is the binary pin in
#13, deliberately not in this pull request. See "The 32 are
an open item" above.
Coverage
Split three ways under decision 0004:
bin/layer-measure-lib(capture and theformats),
bin/layer-compare-lib(whether a difference is noise),bin/layer-report-lib(the blocks and the preconditions), withbt-layer-measurea thin main. Measured withtests/coverage.sh 99:All four new files at 100 percent line and branch: every subcommand, every exit
code, every verdict and every error path, including the two guards that must
never fire, which
tests/measurereaches by replacingmeasure_verdictwith astub. The gate stays at 99, the lowest file rounded down.
shellcheckis clean at-S style, which closes the four info-level notes inthe review (SC2016, SC2015, SC2086 twice) and the ones the rework introduced.
Test plan
tests/measureandtests/comparepass; so dotests/layer,tests/aplinfoandtests/signaturewithpipefailaddedtests/coverage.sh 99exits 0, numbers above and inCOVERAGE.mdshellcheck -S styleexits 0 on all seven touched shell filestests/compareMEASURE_STATE_PATHSsubstitution and the identical-tag run, bothnow refused, both cases in
tests/measurenothing else built, nothing published
global_priv/user.frmverdicts confirmed against the bytesone at a time, table above
tests/compare,and the clock they hid behind still
noiseat both prefix lengthstests/measuretests/comparetests/comparepaths, prints their bytes, and the run stays FAIL on the 32
realincluding
./var/lib/**, cases intests/compareprefix, a case in
tests/measureand the variable cannot move the anchor, cases in
tests/measurein
COVERAGE.mdtests / coveragegreen on this pull request