AtlatestRepositoryfolio

folio / tree / testgate-f2b.sh

1#!/bin/sh
2# gate-f2b.sh -- the F2b gate: dual-format discovery, the bounded worker, and the
3# native/WASM revision conformance vector.
4#
5# RUN: sh test/gate-f2b.sh (all arms)
6# sh test/gate-f2b.sh 4 5 (only those arms)
7# SABOTAGE: sh test/sabotage-f2b.sh (proves these arms can go red)
8#
9# WHAT THIS GATE IS ARRANGED TO AVOID
11# Every arm below either observes a positive control in the same run, or is an
12# assertion about something this gate made happen on purpose. The specific
13# failure being designed against is the one this estate keeps catching: a gate
14# that is GREEN BECAUSE NOTHING COULD MAKE IT RED. Concretely --
16# - Arm 4 requires the bounds to FIRE and, in the same call, to STAND DOWN. A
17# `ulimit`/kill pair that fired unconditionally would pass a firing-only
18# check and would break every parse in production.
19# - Arm 5 requires the killer note to be contained AND the ordinary notes in
20# the same scan to come back indexed. Containment alone is satisfied by a
21# service that fails everything.
22# - Arm 8 requires the conformance vector to CATCH a known-wrong implementation
23# before its agreement between the real ones counts for anything. A vector
24# that has only ever seen agreement has not been shown to detect
25# disagreement.
26# - Arm 9 asserts the state its toolchain is ACTUALLY in and says which one
27# that was. It cannot skip: a version-gated arm that skipped would report
28# green for the same reason a missing tool does.
30# SETUP-FAILED IS A DISTINCT OUTCOME from RED and from GREEN. Where this gate
31# cannot establish its subject, it says so in those words and exits non-zero
32# without claiming anything about the product.
34set -u
36repo_dir=$(cd "$(dirname "$0")/.." && pwd)
37cd "$repo_dir" || exit 1
39# Scratch lives in /tmp. The one exception is `sigil build`, which writes
40# `build/dev/...` under the repo root -- gitignored, but it IS a write, and the
41# earlier version of this comment claimed otherwise. No SOURCE file is touched.
42work=$(mktemp -d /tmp/gate-f2b.XXXXXX) || { echo "SETUP-FAILED: no temp dir"; exit 3; }
43trap 'chmod -R u+rwX "$work" 2>/dev/null; rm -rf "$work"' EXIT INT TERM
45sigil="${SIGIL:-sigil}"
46redirects="${SIGIL_F2B_REDIRECTS:-$repo_dir/dev-redirects.sgl}"
47export SIGIL_REDIRECTS="$redirects"
48VECTORS="$repo_dir/test/fixtures/f2b-revision-vectors"
50fails=0
51fail () { echo "GATE-F2B FAIL: $*" >&2; fails=$((fails + 1)); }
52setup_failed () { echo "GATE-F2B SETUP-FAILED: $*" >&2; exit 3; }
54# An arm's summary line must not be able to say OK when that arm failed.
56# This is not tidiness. `fail` does not abort the arm -- deliberately, so one
57# broken assertion does not hide the five after it -- so an unconditional
58# "arm N OK" at the bottom prints next to its own failures and the transcript
59# reads green to anyone skimming summary lines. Observed on this gate's first
60# run: arm 1 reported two failures and then printed "arm 1 OK".
62# `arm_begin` snapshots the failure count; `arm_ok` prints the summary only if
63# nothing was added since, and otherwise says the arm failed.
64arm_fails_at_start=0
65arm_begin () { arm_fails_at_start=$fails; echo "--- $*"; }
66arm_ok () {
67 if [ "$fails" -eq "$arm_fails_at_start" ]; then
68 echo "$*"
69 else
70 echo "(no OK line: this arm recorded $((fails - arm_fails_at_start)) failure(s) above)"
71 fi
74want_arms="$*"
75should_run () {
76 [ -z "$want_arms" ] && return 0
77 for a in $want_arms; do [ "$a" = "$1" ] && return 0; done
78 return 1
81plain () { sed 's/\x1b\[[0-9;]*m//g' "$@"; }
83# ---------------------------------------------------------------------------
84# INPUTS. Recorded, not assumed. A caret range is an unrecorded input, so the
85# RESOLVED identities are printed -- results in this transcript are true of this
86# (tree, toolchain, dependency) triple and of nothing else.
87# ---------------------------------------------------------------------------
88command -v "$sigil" >/dev/null 2>&1 || setup_failed "no \`$sigil\` on PATH"
89command -v node >/dev/null 2>&1 || setup_failed "no \`node\` on PATH; the WASM conformance arm cannot run, and skipping it would report green for the same reason a missing tool does"
90[ -f "$redirects" ] || setup_failed "no redirects file at $redirects"
91[ -d "$VECTORS" ] || setup_failed "no revision vectors at $VECTORS"
93compiler_version=$("$sigil" --version 2>&1 | plain | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
94[ -n "$compiler_version" ] || setup_failed "could not read the compiler version"
95sigil_doc_dir=$(sed -n 's/.*"codeberg:sigil\/sigil-doc".*//p' "$redirects" >/dev/null 2>&1; echo "$repo_dir/../sigil-doc")
96sigil_doc_sha=$(git -C "$sigil_doc_dir" rev-parse --short HEAD 2>/dev/null || echo "UNKNOWN")
97sigil_doc_desc=$(git -C "$sigil_doc_dir" describe --tags 2>/dev/null || echo "UNKNOWN")
99echo "=========================================================================="
100echo "gate-f2b inputs"
101echo " repo : $repo_dir @ $(git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null || echo UNKNOWN)"
102echo " sigil : $compiler_version ($("$sigil" --version 2>&1 | plain | grep -oE '\([0-9a-f]+\)' | head -1))"
103echo " sigil-doc : $sigil_doc_sha $sigil_doc_desc (via dev-redirects, a WORKING TREE not a tag)"
104echo " node : $(node --version)"
105echo " redirects : $redirects"
106echo " scratch : $work"
107echo "=========================================================================="
109# The library tree must exist and be current; `sigil test` does not build it.
110if ! "$sigil" build --redirects "$redirects" > "$work/build.log" 2>&1; then
111 tail -30 "$work/build.log" >&2
112 setup_failed "the library tree would not build; no arm below is evidence about anything"
113fi
114[ -d build/dev/lib ] || setup_failed "build reported success but build/dev/lib is absent"
116# ---------------------------------------------------------------------------
117# WHICH SHAPE IS UNDER TEST
119# F2B_SHAPE=dev (default) the worker script under the `sigil` runtime,
120# with `-L` paths. Cannot ship.
121# F2B_SHAPE=bundled the folio bundle itself, re-execed under the worker
122# marker. THIS IS WHAT SHIPS.
124# Both drive the SAME payload -- `(folio doc worker)` -- so a difference between
125# the shapes is a difference in how the process is started, not in what it runs.
126# That is deliberate: containment was proven for the dev shape, and bundling
127# changes what is spawned, so the claim has to be RE-PROVEN rather than assumed
128# to transfer. Running the identical arms against both is what makes the
129# comparison mean anything.
131# The gate itself still runs under `sigil` in both shapes -- it is a test harness,
132# not a deployed artifact. What varies is the CHILD the service spawns, which is
133# the thing whose deployability is in question.
134shape="${F2B_SHAPE:-dev}"
135case "$shape" in
136 dev)
137 WORKER="$sigil -L build/dev/lib -L src tools/f2b_inspect_worker.sgl"
138 # Sigil expression for the service's worker argv, spliced into probes below.
139 WORKER_ARGV='(default-worker-argv "build/dev/lib" "src")'
140 ;;
141 bundled)
142 # THE BUNDLE ITSELF IS THE WORKER. There is no second file.
143 #
144 # The gate supplies only the PATH -- it cannot know it otherwise, since it
145 # runs under `sigil` rather than inside the bundle. The marker, the `exec` and
146 # the quoting all come from the product's own `worker-argv-for`, so a probe
147 # here cannot pass by reimplementing what it is meant to check.
148 bundled_worker="$repo_dir/build/dev/bin/folio"
149 [ -e "$bundled_worker" ] || setup_failed "F2B_SHAPE=bundled but $bundled_worker does not exist; run \`sigil build\` first"
151 # THE PREFLIGHT ASKS WHETHER THE SHAPE STARTS, NOT WHETHER IT PARSES.
152 #
153 # It once ran a full inspection and demanded success. Sabotage leg 6 plants
154 # uppercase hex in the revision contract; the worker computes revisions, so
155 # the planted worker legitimately fails to parse -- and the preflight reported
156 # SETUP-FAILED before the arm could catch the defect. The leg came back
157 # INCONCLUSIVE: honest, and proving nothing.
158 #
159 # A PREFLIGHT MUST NOT BE ABLE TO FAIL FOR ANY REASON A LEG IS TRYING TO
160 # CAUSE. The no-argument usage path exercises marker dispatch and process
161 # start and touches no parsing, hashing or classification.
162 bw_usage=$(env -i PATH=/usr/bin:/bin HOME=/tmp FOLIO_DOC_WORKER=1 "$bundled_worker" 2>&1 | head -1)
163 case "$bw_usage" in
164 *"usage: FOLIO_DOC_WORKER=1 folio"*) : ;;
165 *) setup_failed "the bundled worker did not start under a stripped PATH (expected its usage line, got: '$bw_usage'). The shape under test is broken before any arm." ;;
166 esac
167 # Same shape the PRODUCT computes in `bundled-worker-argv`: re-exec the
168 # binary with the marker set, via POSIX sh. Written here as the equivalent
169 # command so direct-invocation arms match what the service does.
170 WORKER="env FOLIO_DOC_WORKER=1 $bundled_worker"
171 WORKER_ARGV="(worker-argv-for \"$bundled_worker\")"
172 ;;
173 *) setup_failed "unknown F2B_SHAPE '$shape' (expected dev or bundled)" ;;
174esac
176echo " SHAPE UNDER TEST: $shape"
177echo " worker child : $WORKER"
178echo "=========================================================================="
180run_sgl () { "$sigil" -L build/dev/lib -L src "$@"; }
182# EVERY ARM MUST PRODUCE ITS OWN SUBJECT.
184# Found by an adversarial review, and it voided three sabotage legs. Arms 2 and 3
185# used to grep `$work/disc.out`, which only arm 1 created, and arm 10 greps
186# `$work/scan.out`, which only arm 5 created and `$ok_root`, which only arm 2 set
187# (under `set -u`). So `sh test/gate-f2b.sh 3` failed on a CLEAN tree -- and
188# sabotage legs 1, 2 and 8, which invoke exactly those single arms, reported "RED
189# as required" with plausible `GATE-F2B FAIL` lines **whether or not their plant
190# did anything**.
192# Three legs of evidence that proved nothing, and the failure text looked right.
193# The documented usage `sh test/gate-f2b.sh 4 5` invites precisely this.
195# So the shared subjects are built by idempotent `ensure_*` helpers that every
196# dependent arm calls. An arm run alone now measures what it claims to.
197ensure_disc () {
198 [ -f "$work/disc.out" ] && return 0
199 cat > "$work/disc.sgl" <<EOF
200(import (sigil core) (folio doc discovery))
201(define c (discover-sources "$corpus"))
202(display (census->report c))
203(display (format "MACHINE\texamined=~a\tsgd=~a\tmd=~a\tskipped=~a\tbalanced=~a\tcomplete=~a\tcollisions=~a~%"
204 (census-examined c) (census-count-by-format c 'sgd)
205 (census-count-by-format c 'markdown) (length (census-skipped c))
206 (census-balanced? c) (census-complete? c) (length (census-collisions c))))
207EOF
208 run_sgl "$work/disc.sgl" > "$work/disc.out" 2>&1 \
209 || { cat "$work/disc.out" >&2; fail "discovery probe did not run"; }
212ok_root="$work/readable"
213ensure_ok_root () {
214 [ -d "$ok_root" ] && return 0
215 mkdir -p "$ok_root/notes"
216 printf '#lang sigil/doc\n@note["topics/r" title: "R"]\n\n@p{x}\n\n@end note\n' > "$ok_root/notes/r.sgd"
217 cat > "$work/disc2.sgl" <<EOF
218(import (sigil core) (folio doc discovery))
219(display (census->report (discover-sources "$ok_root")))
220EOF
221 run_sgl "$work/disc2.sgl" > "$work/disc2.out" 2>&1
224ensure_scan () {
225 [ -f "$work/scan.out" ] && return 0
226 cat > "$work/scan.sgl" <<EOF
227(import (sigil core) (folio doc service) (folio doc bounded))
228(define svc (make-service $WORKER_ARGV
229 (make-limits 1048576 5000)))
230(define r (scan-root svc "$corpus"))
231(display (result->report r))
232(for-each (lambda (e)
233 (display (format "ENTRY\t~a\t~a\t~a~%"
234 (entry-slug e) (entry-status e) (entry-reason e))))
235 (result-entries r))
236(display (format "ACCOUNTED\t~a~%" (result-accounted? r)))
237EOF
238 run_sgl "$work/scan.sgl" > "$work/scan.out" 2>&1
241# ---------------------------------------------------------------------------
242# CORPUS. Built here rather than committed, so each arm's inputs are visible in
243# the script that asserts on them.
244# ---------------------------------------------------------------------------
245corpus="$work/corpus"
246mkdir -p "$corpus/notes/topics" "$corpus/notes/locked"
247printf '#lang sigil/doc\n@note["topics/alpha" title: "Alpha"]\n\n@p{ordinary}\n\n@end note\n' > "$corpus/notes/topics/alpha.sgd"
248printf '#lang sigil/doc\n@note["topics/beta" title: "Beta"]\n\n@p{ordinary}\n\n@end note\n' > "$corpus/notes/topics/beta.sgd"
249printf '# Gamma\n\nmarkdown\n' > "$corpus/notes/topics/gamma.md"
250cp test/fixtures/f2a-malformed.sgd "$corpus/notes/topics/broken.sgd"
251printf '#lang sigil/doc\n@note["topics/dup-one" title: "One"]\n\n@task["shared-id" title: "T" status: ready]{x}\n\n@end note\n' > "$corpus/notes/topics/dup-one.sgd"
252printf '#lang sigil/doc\n@note["topics/dup-two" title: "Two"]\n\n@task["shared-id" title: "T" status: ready]{x}\n\n@end note\n' > "$corpus/notes/topics/dup-two.sgd"
253# The .sgd/.md slug collision F2's sabotage list names.
254printf '#lang sigil/doc\n@note["topics/clash" title: "C"]\n\n@p{sgd}\n\n@end note\n' > "$corpus/notes/topics/clash.sgd"
255printf '# Clash\n\nmd\n' > "$corpus/notes/topics/clash.md"
256printf 'not a note\n' > "$corpus/notes/topics/readme.txt"
257ln -s /nonexistent/target "$corpus/notes/topics/dangling.md"
258printf '# swap\n' > "$corpus/notes/topics/.beta.md.swp"
259printf '# inside\n' > "$corpus/notes/locked/inside.md"
260chmod 000 "$corpus/notes/locked"
262# The slow note: 4 MB of body. Measured at ~90 kB/s parse throughput, so this
263# takes roughly 45 s unbounded -- comfortably past the short wall limit arm 5
264# imposes, and far enough past it that the arm is not timing-marginal.
265{ printf '#lang sigil/doc\n@note["topics/slow" title: "S"]\n\n@p{'
266 head -c 4000000 /dev/zero | tr '\0' 'z'
267 printf '}\n\n@end note\n'; } > "$corpus/notes/topics/slow.sgd"
269# ---------------------------------------------------------------------------
270# ARM 1 -- DISCOVERY FINDS BOTH FORMATS AND REPORTS ITS DENOMINATOR
271# ---------------------------------------------------------------------------
272if should_run 1; then
273 arm_begin "arm 1: dual-format discovery and the denominator"
274 ensure_disc
275 m=$(grep '^MACHINE' "$work/disc.out")
276 # BOTH formats must be found. A discovery layer that found only .md would
277 # satisfy "it ran" and fail the entire point of the phase.
278 echo "$m" | grep -q 'sgd=7' || { echo "$m" >&2; fail "arm 1: expected 7 .sgd candidates (alpha beta broken dup-one dup-two clash slow)"; }
279 echo "$m" | grep -q 'md=2' || { echo "$m" >&2; fail "arm 1: expected 2 .md candidates (gamma clash; dangling.md is a SKIP, not a candidate)"; }
280 # THE ACCOUNTING IDENTITY. This is what makes `examined` a denominator.
281 echo "$m" | grep -q 'balanced=#t' || { echo "$m" >&2; fail "arm 1: census does not balance -- examined != candidates + skipped"; }
282 # SKIPPED entries must NAME A REASON. A skip with no reason is a silent drop
283 # wearing a count.
284 grep -q 'not-a-source-format' "$work/disc.out" || fail "arm 1: the .txt file was not skipped with a named reason"
285 grep -q 'not-a-regular-file' "$work/disc.out" || fail "arm 1: the dangling symlink was not skipped with a named reason"
286 arm_ok "arm 1 OK: both formats discovered; examined = candidates + skipped; every skip names a reason"
287fi
289# ---------------------------------------------------------------------------
290# ARM 2 -- "SKIPPED" AND "NOT SEEN" DO NOT PRINT THE SAME WAY
292# The corpus contains a directory that will not enumerate. Its files are in NO
293# bucket and are NOT in `examined`, so every count is a lower bound. The census
294# must say that in its own words, and -- the half that is easy to forget -- must
295# NOT say it when the root is fully readable.
296# ---------------------------------------------------------------------------
297if should_run 2; then
298 arm_begin "arm 2: skipped vs not-seen"
299 ensure_disc; ensure_ok_root
300 grep -q 'completeness : INCOMPLETE' "$work/disc.out" \
301 || { grep completeness "$work/disc.out" >&2; fail "arm 2: an unreadable directory did not make the census INCOMPLETE"; }
302 grep -q 'LOWER BOUNDS' "$work/disc.out" \
303 || fail "arm 2: the incomplete census does not say its counts are lower bounds"
304 # THE TWO FACTS MUST BE SEPARATELY OBSERVABLE, on a root where they DISAGREE.
305 #
306 # An earlier version of this check greped the shared corpus for "INCOMPLETE and
307 # 0 skips" -- unfalsifiable, because that corpus always contains two skips
308 # regardless of the not-seen machinery, so the conjunction could never be true.
309 # Found by an adversarial review.
310 #
311 # The real question is whether a root with an unreadable directory and NO skips
312 # still reports INCOMPLETE. That is the case where conflating the two would be
313 # invisible, so it is the case worth building.
314 onlyloop="$work/onlyloop"; mkdir -p "$onlyloop/notes/shut"
315 printf '#lang sigil/doc\n@note["topics/o" title: "O"]\n\n@p{x}\n\n@end note\n' > "$onlyloop/notes/o.sgd"
316 printf '# hidden\n' > "$onlyloop/notes/shut/inside.md"
317 chmod 000 "$onlyloop/notes/shut"
318 cat > "$work/onlyloop.sgl" <<EOF
319(import (sigil core) (folio doc discovery))
320(display (census->report (discover-sources "$onlyloop")))
321EOF
322 run_sgl "$work/onlyloop.sgl" > "$work/onlyloop.out" 2>&1
323 chmod 755 "$onlyloop/notes/shut" 2>/dev/null
324 if grep -qE '^ skipped +: 0' "$work/onlyloop.out"; then
325 grep -q 'completeness : INCOMPLETE' "$work/onlyloop.out" \
326 || { cat "$work/onlyloop.out" >&2; fail "arm 2: a root with ZERO skips and an unreadable directory did not report INCOMPLETE -- 'skipped' and 'not seen' are being reported as the same fact"; }
327 echo " [zero skips + one unreadable dir] -> skipped 0 AND completeness INCOMPLETE, independently"
328 else
329 # If the corpus grew skips, say so rather than passing silently: the check
330 # needs a zero-skip root to mean anything.
331 fail "arm 2: the zero-skip fixture produced skips, so the skipped-vs-not-seen separation was NOT tested"
332 fi
334 # POSITIVE CONTROL: the same walker over a fully readable root must report
335 # COMPLETE. Without this, a census hard-coded to say INCOMPLETE would pass.
336 grep -q 'completeness : COMPLETE' "$work/disc2.out" \
337 || { cat "$work/disc2.out" >&2; fail "arm 2 POSITIVE CONTROL: a fully readable root did not report COMPLETE, so INCOMPLETE means nothing"; }
338 arm_ok "arm 2 OK: unreadable dir -> INCOMPLETE + lower-bounds language; readable root -> COMPLETE (control)"
339fi
341# ---------------------------------------------------------------------------
342# ARM 3 -- THE TWO-FORMAT COLLISION IS REFUSED, NOT RESOLVED
344# F2's sabotage list: "silently prefer `.sgd` duplicate over `.md`".
345# ---------------------------------------------------------------------------
346if should_run 3; then
347 arm_begin "arm 3: .sgd/.md slug collision"
348 ensure_disc
349 grep -q 'slug conflicts: 1' "$work/disc.out" \
350 || { grep 'slug conflicts' "$work/disc.out" >&2; fail "arm 3: the clash.sgd/clash.md collision was not reported"; }
351 # BOTH sides must be named. Reporting one is how a silent preference looks
352 # from the outside.
353 grep -q 'clash.md' "$work/disc.out" || fail "arm 3: the collision report does not name the .md side"
354 grep -q 'clash.sgd' "$work/disc.out" || fail "arm 3: the collision report does not name the .sgd side"
355 arm_ok "arm 3 OK: collision reported with BOTH sides named; neither format silently preferred"
356fi
358# ---------------------------------------------------------------------------
359# ARM 4 -- THE BOUNDS FIRE, AND THE BOUNDS STAND DOWN
360# ---------------------------------------------------------------------------
361if should_run 4; then
362 arm_begin "arm 4: bounded-worker self-test"
363 cat > "$work/self.sgl" <<'EOF'
364(import (sigil core) (folio doc bounded))
365(for-each (lambda (r)
366 (display (format "SELFTEST\t~a\t~a\t~a\t~a~%"
367 (car r) (cadr r) (caddr r) (if (cadddr r) "PASS" "FAIL"))))
368 (bounded-self-test))
369EOF
370 if ! run_sgl "$work/self.sgl" > "$work/self.out" 2>&1; then
371 cat "$work/self.out" >&2; fail "arm 4: the self-test did not run"
372 else
373 cat "$work/self.out"
374 grep -q 'memory cap fires.*PASS' "$work/self.out" || fail "arm 4: the memory cap did not fire on a process that must exceed it -- the cap may not be applied at all"
375 grep -q 'wall timeout fires.*PASS' "$work/self.out" || fail "arm 4: the wall timeout did not fire on a process that must exceed it"
376 # THE RESTRAINT HALF. Without it, bounds that fired unconditionally pass.
377 grep -q 'bounds stand down.*PASS' "$work/self.out" || fail "arm 4: the bounds fired on an ordinary fast child -- a bound that fires on everything is not a bound"
378 [ "$(grep -c 'FAIL' "$work/self.out")" = "0" ] || fail "arm 4: a self-test probe reported FAIL"
379 arm_ok "arm 4 OK: both bounds proven able to fire AND proven to stand down, in one call"
380 fi
381fi
383# ---------------------------------------------------------------------------
384# ARM 5 -- CONTAINMENT THROUGH THE SERVICE, WITH THE RESTRAINT CONTROL
386# The service scans a root containing a note measured to overrun the limits. It
387# must survive, classify that note `unindexed` with a resource reason, AND index
388# the ordinary notes in the SAME scan.
389# ---------------------------------------------------------------------------
390if should_run 5; then
391 arm_begin "arm 5: the service survives an overrunning parse"
392 ensure_scan
393 if ! grep -q '^ACCOUNTED' "$work/scan.out"; then
394 cat "$work/scan.out" >&2
395 fail "arm 5: THE SCAN ITSELF DIED. This is the exact outcome the bounded worker exists to prevent."
396 else
397 # The overrunning note is contained, with a RESOURCE reason.
398 grep -qE '^ENTRY notes/topics/slow unindexed (wall-timeout|memory-cap)' "$work/scan.out" \
399 || { grep '^ENTRY' "$work/scan.out" >&2; fail "arm 5: the overrunning note was not contained as unindexed/resource"; }
400 # THE RESTRAINT CONTROL: ordinary notes in the same run must be INDEXED.
401 # Without this the arm is satisfied by a service that fails every note.
402 grep -q '^ENTRY notes/topics/alpha indexed' "$work/scan.out" \
403 || { grep '^ENTRY' "$work/scan.out" >&2; fail "arm 5 RESTRAINT CONTROL: an ordinary note did not index in the same scan; the bounds are firing on everything"; }
404 grep -q '^ENTRY notes/topics/beta indexed' "$work/scan.out" \
405 || fail "arm 5 RESTRAINT CONTROL: a second ordinary note did not index"
406 # A genuinely broken document is DOCUMENT-REJECTED, which is a different
407 # answer from unindexed and must stay different.
408 grep -q '^ENTRY notes/topics/broken document-rejected' "$work/scan.out" \
409 || { grep '^ENTRY' "$work/scan.out" >&2; fail "arm 5: a document with located diagnostics was not classified document-rejected"; }
410 # Markdown is retained, not dropped and not failed.
411 grep -q '^ENTRY notes/topics/gamma not-inspectable' "$work/scan.out" \
412 || fail "arm 5: the Markdown note was not retained as not-inspectable"
413 # Every discovered candidate has exactly one entry.
414 grep -q '^ACCOUNTED #t' "$work/scan.out" \
415 || fail "arm 5: candidates and entries do not reconcile -- a note went missing between discovery and inspection"
416 arm_ok "arm 5 OK: overrunning note contained; ordinary notes indexed in the same scan; broken != unindexed; markdown retained"
417 fi
418fi
420# ---------------------------------------------------------------------------
421# ARM 6 -- A DEAD, TRUNCATED, OR ABSENT WORKER IS CONTAINED AND CORRECTLY NAMED
423# The reader pathology that motivated the bounded worker is fixed on this
424# toolchain (arm 9), so a manuscript can no longer kill the parser. The crash
425# path is therefore proven with children that genuinely die -- otherwise this
426# clause of the contract would have no evidence at all on a fixed toolchain.
427# ---------------------------------------------------------------------------
428if should_run 6; then
429 arm_begin "arm 6: dead / truncated / absent worker"
430 small="$work/small"; mkdir -p "$small/notes"
431 printf '#lang sigil/doc\n@note["topics/s" title: "S"]\n\n@p{x}\n\n@end note\n' > "$small/notes/s.sgd"
432 probe_worker () { # $1 = label, $2 = expected reason, $3.. = argv
433 label=$1; expected=$2; shift 2
434 argv_sgl=$(for a in "$@"; do printf '"%s" ' "$a"; done)
435 cat > "$work/crash.sgl" <<EOF
436(import (sigil core) (folio doc service) (folio doc bounded))
437(define svc (make-service (list $argv_sgl) (make-limits 1048576 5000)))
438(define r (scan-root svc "$small"))
439(for-each (lambda (e) (display (format "R\t~a\t~a~%" (entry-status e) (entry-reason e))))
440 (result-entries r))
441(display "SURVIVED\n")
442EOF
443 if ! run_sgl "$work/crash.sgl" > "$work/crash.out" 2>&1; then
444 cat "$work/crash.out" >&2; fail "arm 6 [$label]: the SCAN died rather than containing the worker"
445 return
446 fi
447 grep -q '^SURVIVED' "$work/crash.out" || fail "arm 6 [$label]: the scan did not complete"
448 grep -q "^R unindexed $expected" "$work/crash.out" \
449 || { cat "$work/crash.out" >&2; fail "arm 6 [$label]: expected unindexed/$expected"; }
450 echo " [$label] -> unindexed/$expected, scan survived"
451 }
452 probe_worker "SIGSEGV" worker-crashed sh -c 'kill -SEGV $$' worker
453 probe_worker "non-zero exit" worker-crashed sh -c 'exit 7' worker
454 # A child that printed plausible output but never reached its completion
455 # marker. This is the truncation case, and calling it clean is the free-pass
456 # absence claim the marker exists to refuse.
457 probe_worker "truncated" worker-crashed sh -c 'echo SOURCE; echo NODES 3' worker
458 # A worker that was never there is SETUP-FAILED, not a crash: it says nothing
459 # about the document, and a misconfigured path must not report the whole estate
460 # as broken.
461 probe_worker "absent program" setup-failed definitely-not-a-real-program-xyz
462 # THE MARKER MUST NOT BE FORGEABLE BY THE DOCUMENT.
463 #
464 # The worker prints the document's own node ids. If the completion marker were
465 # matched as a SUBSTRING, a note carrying `@task["F2B_WORKER_DONE" ...]` would
466 # emit `ID<TAB>unique<TAB>F2B_WORKER_DONE` and thereby certify its own truncated
467 # run -- the document supplying the token that says the worker finished.
468 # `has-exact-line?` is what prevents that, and this probe is what makes that
469 # choice falsifiable.
470 cat > "$work/forge.sgl" <<'EOF'
471(import (sigil core) (folio doc bounded))
472(define forged "SOURCE\tx.sgd\nID\tunique\tF2B_WORKER_DONE\n")
473(define o (classify-worker-output forged "" 0 5 default-limits))
474(display (format "FORGE\t~a\t~a~%" (outcome-kind o) (outcome-reason o)))
475EOF
476 run_sgl "$work/forge.sgl" > "$work/forge.out" 2>&1
477 grep -q '^FORGE unindexed worker-crashed' "$work/forge.out" \
478 || { cat "$work/forge.out" >&2; fail "arm 6: a document whose node id is the completion marker forged a clean verdict for a truncated run"; }
479 echo " [document forges the completion marker in a node id] -> unindexed/worker-crashed"
481 arm_ok "arm 6 OK: crash, truncation, absence and marker-forgery each contained and named distinctly"
482fi
484# ---------------------------------------------------------------------------
485# ARM 7 -- `parse-error-unlocated` IS A SUSPECT INSTRUMENT, NOT A BROKEN DOCUMENT
487# F2a's carry-forward, and on this toolchain it is reproducible rather than
488# theoretical: a healthy 1 MB manuscript reports
489# `parse-error-unlocated: list->string: out of memory` under a squeezed cap and
490# parses cleanly with headroom. The arm proves BOTH directions, because only
491# the pair establishes that the code tracks the host rather than the document.
492# ---------------------------------------------------------------------------
493if should_run 7; then
494 arm_begin "arm 7: parse-error-unlocated is instrument-suspect"
495 sq="$work/squeeze"; mkdir -p "$sq/notes"
496 { printf '#lang sigil/doc\n@note["topics/big" title: "B"]\n\n@p{'
497 head -c 1000000 /dev/zero | tr '\0' 'z'
498 printf '}\n\n@end note\n'; } > "$sq/notes/big.sgd"
500 # (a) SQUEEZED. THE CAP IS DERIVED, NOT HARDCODED.
501 #
502 # This arm used a fixed 300 MB cap, which squeezed the DEV worker (it needs
503 # somewhere between 300 and 500 MB for a 1 MB manuscript) and DID NOT squeeze
504 # the BUNDLED worker at all -- measured, the bundle parses the same note
505 # cleanly at 300 MB and only fails between 200 and 300 MB. So on the shape that
506 # actually ships, the arm was testing nothing and said so by failing.
507 #
508 # Tuning the constant down until it went green would have been the wrong repair
509 # twice over: it hardcodes a second shape's number, and it hides that the two
510 # shapes have materially different memory profiles. Instead the arm WALKS THE
511 # CAP DOWN until the worker actually fails, and reports the operating point it
512 # found. If no cap in the ladder produces a failure, the arm cannot establish
513 # its subject and says SETUP-FAILED -- it never passes by not squeezing.
514 squeeze_cap=""
515 for cap in 400000 300000 250000 200000 150000 120000; do
516 cat > "$work/sqprobe.sgl" <<EOF
517(import (sigil core) (folio doc service) (folio doc bounded))
518(define svc (make-service $WORKER_ARGV (make-limits $cap 60000)))
519(for-each (lambda (e) (display (format "SQ\t~a\t~a~%" (entry-status e) (entry-reason e))))
520 (result-entries (scan-root svc "$sq")))
521EOF
522 run_sgl "$work/sqprobe.sgl" > "$work/sq.out" 2>&1
523 if grep -q '^SQ indexed' "$work/sq.out"; then
524 echo " cap ${cap} kB: still parses cleanly (not squeezed yet)"
525 else
526 squeeze_cap="$cap"
527 echo " cap ${cap} kB: the parse stopped succeeding -- operating point for this shape"
528 break
529 fi
530 done
532 if [ -z "$squeeze_cap" ]; then
533 setup_failed "arm 7: no address-space cap in the ladder made the $shape worker fail on a 1 MB manuscript, so the classification under memory pressure CANNOT BE TESTED. This is not a pass."
534 fi
536 cat "$work/sq.out"
537 # THE ASSERTION: whatever the failure was, it must not be blamed on the document.
538 if grep -q '^SQ document-rejected' "$work/sq.out"; then
539 fail "arm 7: a HEALTHY document was marked document-rejected because the worker ran out of memory. This is the trust-boundary defect the carry-forward names."
540 fi
541 grep -qE '^SQ unindexed (instrument-suspect|memory-cap|worker-crashed)' "$work/sq.out" \
542 || { cat "$work/sq.out" >&2; fail "arm 7: the squeezed parse was not classified as a resource/instrument failure"; }
544 # (b) POSITIVE CONTROL, and the half that proves the claim: THE SAME BYTES with
545 # headroom must index cleanly. Without it, (a) is satisfied by a document that
546 # is simply broken.
547 cat > "$work/sq2.sgl" <<EOF
548(import (sigil core) (folio doc service) (folio doc bounded))
549(define svc (make-service $WORKER_ARGV
550 (make-limits 2097152 120000)))
551(for-each (lambda (e) (display (format "OK\t~a\t~a~%" (entry-status e) (entry-reason e))))
552 (result-entries (scan-root svc "$sq")))
553EOF
554 run_sgl "$work/sq2.sgl" > "$work/sq2.out" 2>&1
555 cat "$work/sq2.out"
556 grep -q '^OK indexed' "$work/sq2.out" \
557 || { cat "$work/sq2.out" >&2; fail "arm 7 POSITIVE CONTROL: the same bytes did not index with headroom, so the squeezed result says nothing about memory"; }
558 # (c) THE RULE ITSELF, EXERCISED DIRECTLY.
559 #
560 # Sabotage leg 4 -- "treat parse-error-unlocated as a broken document" -- STAYED
561 # GREEN against (a) and (b) alone, and that was a real gate defect rather than a
562 # pass. The reason: a squeezed parse emits a memory signature, so it is
563 # classified `memory-cap` by an EARLIER rule and the unlocated rule is never
564 # reached. Arm 7 was therefore testing the memory rule while claiming to test
565 # the unlocated one.
566 #
567 # These probes drive the real classifier with a worker whose output contains an
568 # unlocated diagnostic and NO resource wording, which is the only way to reach
569 # rule 3. Paired with a located-error probe, so the arm proves the two codes are
570 # treated DIFFERENTLY rather than merely that one of them is non-fatal.
571 probe_code () { # $1 = label, $2 = the DIAGNOSTIC line, $3 = expected status, $4 = expected reason
572 cat > "$work/code.sgl" <<EOF
573(import (sigil core) (folio doc bounded))
574(define o (classify-worker-output "$2\nF2B_WORKER_DONE\n" "" 0 12 default-limits))
575(display (format "CODE\t~a\t~a\t~a~%" (outcome-kind o) (outcome-reason o)
576 (outcome-document-at-fault? o)))
577EOF
578 run_sgl "$work/code.sgl" > "$work/code.out" 2>&1
579 got=$(grep '^CODE' "$work/code.out")
580 if ! echo "$got" | grep -q "^CODE $3 $4"; then
581 cat "$work/code.out" >&2
582 fail "arm 7 [$1]: expected $3/$4, got: $got"
583 else
584 echo " [$1] -> $3/$4"
585 fi
586 }
587 probe_code "unlocated code, no resource wording" \
588 'DIAGNOSTIC\terror\tparse-error-unlocated\tx.sgd:?:?: error: parse-error-unlocated: the reader gave up' \
589 unindexed instrument-suspect
590 # THE CONTRAST that makes the above mean something: a LOCATED error must still
591 # convict the document. Without this, "never document-rejected" is satisfied by
592 # a classifier that never rejects anything.
593 probe_code "located code, same shape" \
594 'DIAGNOSTIC\terror\tparse-error\tx.sgd:3:1: error: parse-error: unexpected token' \
595 document-rejected parse-error
597 # (d) DOCUMENT TEXT MUST NOT BE ABLE TO STEER THE CLASSIFIER.
598 #
599 # The first version of this module searched all of stdout for a memory
600 # signature. A healthy note carrying @task["out of memory" ...] was then
601 # classified unindexed/memory-cap and never indexed -- document content driving a
602 # resource verdict. Found by an adversarial pass, fixed by scoping the search to
603 # channels the document cannot write into.
604 oom="$work/oomid"; mkdir -p "$oom/notes"
605 printf '#lang sigil/doc\n@note["topics/oom-notes" title: "Notes on errors"]\n\n@task["out of memory" title: "T" status: ready]{x}\n\n@end note\n' > "$oom/notes/a.sgd"
606 cat > "$work/oomid.sgl" <<EOF
607(import (sigil core) (folio doc service) (folio doc bounded))
608(define svc (make-service $WORKER_ARGV default-limits))
609(for-each (lambda (e) (display (format "OOMID\t~a\t~a~%" (entry-status e) (entry-reason e))))
610 (result-entries (scan-root svc "$oom")))
611EOF
612 run_sgl "$work/oomid.sgl" > "$work/oomid.out" 2>&1
613 grep -q '^OOMID indexed' "$work/oomid.out" \
614 || { cat "$work/oomid.out" >&2; fail "arm 7: a HEALTHY note whose node id is the string 'out of memory' was not indexed -- document content is steering the resource classifier"; }
615 echo " [document text cannot steer the classifier] -> indexed"
617 arm_ok "arm 7 OK: same bytes squeezed => retryable / with headroom => indexed; unlocated != located at the classifier; document text cannot forge a resource verdict"
618fi
620# ---------------------------------------------------------------------------
621# ARM 8 -- THE NATIVE/WASM REVISION CONFORMANCE VECTOR
622# ---------------------------------------------------------------------------
623if should_run 8; then
624 arm_begin "arm 8: native/WASM revision conformance"
625 run_sgl test/f2b-revision-native.sgl "$VECTORS" > "$work/native.txt" 2> "$work/native.err" \
626 || { cat "$work/native.err" >&2; fail "arm 8: the native revision arm did not run"; }
627 node test/f2b-revision-webcrypto.js --bytes "$VECTORS" > "$work/wasm.txt" 2> "$work/wasm.err" \
628 || { cat "$work/wasm.err" >&2; fail "arm 8: the WebCrypto revision arm did not run"; }
630 # A FLOOR ON THE POPULATION. Two empty files compare equal, so without this a
631 # vector directory that failed to load would report perfect agreement.
632 n=$(grep -c . "$work/native.txt")
633 [ "$n" -ge 9 ] || fail "arm 8: only $n vectors were hashed; expected at least 9 (a cap without a floor is satisfied by a dead scanner)"
635 # (a) THE TWO REAL IMPLEMENTATIONS MUST AGREE, BYTE FOR BYTE.
636 if ! diff -u "$work/native.txt" "$work/wasm.txt" > "$work/conf.diff" 2>&1; then
637 cat "$work/conf.diff" >&2
638 fail "arm 8: native and WebCrypto revisions DISAGREE. Across clients this surfaces as mysterious lost writes, not as a hash bug."
639 fi
641 # (b) THE DISCRIMINATION TEST -- without which (a) proves nothing. The naive
642 # text-path implementation is a real mistake, not a strawman: reading a file as
643 # TEXT and re-encoding is what `FileReader.readAsText` invites, and it is
644 # silently lossy for ill-formed UTF-8. The vector MUST notice.
645 node test/f2b-revision-webcrypto.js --text "$VECTORS" > "$work/naive.txt" 2>&1 \
646 || fail "arm 8: the naive-implementation arm did not run"
647 if diff -q "$work/native.txt" "$work/naive.txt" >/dev/null 2>&1; then
648 fail "arm 8: THE VECTOR IS WORTHLESS -- it did not distinguish a known-wrong implementation from the correct one. A vector that has only ever seen agreement has not been shown to detect disagreement."
649 fi
650 caught=$(diff "$work/native.txt" "$work/naive.txt" | grep '^<' | cut -f1 | sed 's/^< //' | tr '\n' ' ')
651 echo " vector caught the naive text-path implementation on: $caught"
653 # (c) A THIRD IMPLEMENTATION, sharing no code with either: coreutils.
654 if command -v sha256sum >/dev/null 2>&1; then
655 ( cd "$VECTORS" && for f in *.sgd; do
656 printf '%s\tsha256:%s\n' "$f" "$(sha256sum "$f" | cut -d' ' -f1)"
657 done ) > "$work/coreutils.txt"
658 diff -u "$work/native.txt" "$work/coreutils.txt" > "$work/cu.diff" 2>&1 \
659 || { cat "$work/cu.diff" >&2; fail "arm 8: folio's revisions disagree with coreutils sha256sum"; }
660 echo " third implementation (coreutils sha256sum) agrees on all $n vectors"
661 else
662 echo " NOTE: sha256sum absent, the third-implementation check DID NOT RUN (this is a gap, not a pass)"
663 fi
664 arm_ok "arm 8 OK: $n vectors, native == WebCrypto == coreutils, and the vector demonstrably catches a wrong implementation"
665fi
667# ---------------------------------------------------------------------------
668# ARM 9 -- THE t-f6e098 READER PATHOLOGY, RE-MEASURED ON THIS TOOLCHAIN
670# CANNOT SKIP. It asserts the state its compiler is actually in and names it.
671# ---------------------------------------------------------------------------
672if should_run 9; then
673 arm_begin "arm 9: t-f6e098 on sigil $compiler_version"
674 ver_ge () { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; }
675 oomfx=test/fixtures/f2a-reader-oom.sgd
676 [ -f "$oomfx" ] || setup_failed "the t-f6e098 fixture is missing; the arm has no subject"
678 # PRESENCE PROBE FIRST, with a healthy-positive answer: a clean manuscript must
679 # parse in this same configuration. Without it, "the pathology fixture came
680 # back clean" is equally consistent with nothing parsing at all -- which is
681 # exactly what a registry-resolved sigil-doc produces (every parse fails
682 # identically at 2:6 with `unbound variable 'make-syntax-reader'`).
683 if ! $WORKER test/fixtures/f2a-corpus.sgd > "$work/pres.log" 2>&1; then
684 cat "$work/pres.log" >&2; setup_failed "arm 9 presence probe: a CLEAN manuscript did not inspect; no verdict about the pathology is available"
685 fi
686 grep -q '^PARSED yes' "$work/pres.log" \
687 || { cat "$work/pres.log" >&2; setup_failed "arm 9 presence probe: the clean corpus did not parse; the toolchain is misconfigured (check dev-redirects)"; }
689 # This script runs under `set -u` ONLY -- errexit is never enabled. The
690 # `set +e` / `set -e` pair that used to bracket this probe therefore did not
691 # restore a previous state: it TURNED ERREXIT ON for arm 10 and the summary,
692 # where an unprotected `run_sgl` could abort the script before `GATE-F2B RED`
693 # was ever printed -- and it did so only when arm 9 happened to be selected, so
694 # the gate's behaviour depended on which arms you asked for. Found by an
695 # adversarial review. The status is captured directly instead.
696 ( ulimit -v 1500000; timeout 90 $WORKER "$oomfx" > "$work/oom.log" 2>&1 )
697 oom_status=$?
698 if ver_ge "$compiler_version" "0.18.5"; then
699 [ "$oom_status" = "0" ] || { tail -20 "$work/oom.log" >&2; fail "arm 9: sigil $compiler_version carries the t-f6e098 fix, so the (} shape must yield a clean diagnostic; the probe exited $oom_status"; }
700 grep -qE '^DIAGNOSTIC error parse-error ' "$work/oom.log" \
701 || { tail -20 "$work/oom.log" >&2; fail "arm 9: the (} shape did not produce a LOCATED parse diagnostic on a fixed toolchain"; }
702 arm_ok "arm 9 OK: sigil $compiler_version >= 0.18.5 -- clean located diagnostic REQUIRED and observed; the documented OOM does NOT reproduce"
703 else
704 [ "$oom_status" != "0" ] || { tail -20 "$work/oom.log" >&2; fail "arm 9: sigil $compiler_version predates the fix but the (} shape did NOT fail; this arm no longer describes its subject -- re-derive it"; }
705 grep -qiE 'out of memory|Killed' "$work/oom.log" \
706 || { tail -20 "$work/oom.log" >&2; fail "arm 9: the (} shape failed on sigil $compiler_version, but not with the memory signature t-f6e098 describes"; }
707 echo "arm 9: sigil $compiler_version PREDATES the 0.18.5 fix; the documented OOM is REQUIRED and was observed, contained by the ulimit. THIS IS THE PATHOLOGY, NOT A PASS."
708 fi
709fi
711# ---------------------------------------------------------------------------
712# ARM 10 -- DUPLICATE IDS, WITHIN A DOCUMENT AND ACROSS THE WORKSPACE
713# ---------------------------------------------------------------------------
714if should_run 10; then
715 arm_begin "arm 10: duplicate ids"
716 ensure_scan; ensure_ok_root
717 # ACROSS documents: `shared-id` lives in dup-one.sgd and dup-two.sgd. Both must
718 # be named; naming one is what a silent winner looks like from outside.
719 grep -q 'duplicate ids : 1 id' "$work/scan.out" \
720 || { grep 'duplicate ids' "$work/scan.out" >&2; fail "arm 10: the workspace-level duplicate id was not reported"; }
721 grep -q 'shared-id:' "$work/scan.out" || fail "arm 10: the duplicated id is not named"
722 grep -q 'dup-one.sgd' "$work/scan.out" || fail "arm 10: the first holder of the duplicated id is not named"
723 grep -q 'dup-two.sgd' "$work/scan.out" || fail "arm 10: the second holder of the duplicated id is not named"
724 grep -q 'REFUSED, not resolved' "$work/scan.out" || fail "arm 10: the duplicate report does not say it refuses rather than picks"
726 # WITHIN one document the inspector already owns this; the service must pass
727 # `ambiguous` through rather than re-deriving or flattening it.
728 dupdoc="$work/dupdoc"; mkdir -p "$dupdoc/notes"
729 printf '#lang sigil/doc\n@note["topics/d" title: "D"]\n\n@task["twice" title: "A" status: ready]{x}\n\n@task["twice" title: "B" status: ready]{y}\n\n@end note\n' > "$dupdoc/notes/d.sgd"
730 $WORKER "$dupdoc/notes/d.sgd" > "$work/dupdoc.out" 2>&1
731 grep -q '^ID ambiguous twice' "$work/dupdoc.out" \
732 || { cat "$work/dupdoc.out" >&2; fail "arm 10: an id duplicated WITHIN one document did not come back with status 'ambiguous'"; }
733 grep -q 'duplicate-node-id' "$work/dupdoc.out" \
734 || fail "arm 10: the within-document duplicate produced no duplicate-node-id diagnostic"
736 # POSITIVE CONTROL: a workspace with no duplicates must report none, or
737 # "duplicates found" is unfalsifiable.
738 # (A line here used to grep `disc2.out` for 'duplicate ids' and swallow the
739 # result with `|| true`. `disc2.out` is a discovery CENSUS and never contains
740 # that string, so the line could neither pass nor fail -- it read as an
741 # assertion and was decoration. Removed rather than repaired; the real
742 # zero-duplicates control is the `nodup` probe below, which uses a SERVICE
743 # result, where the string can actually appear.)
744 cat > "$work/nodup.sgl" <<EOF
745(import (sigil core) (folio doc service) (folio doc bounded))
746(define svc (make-service $WORKER_ARGV default-limits))
747(display (result->report (scan-root svc "$ok_root")))
748EOF
749 run_sgl "$work/nodup.sgl" > "$work/nodup.out" 2>&1
750 grep -q 'duplicate ids : none' "$work/nodup.out" \
751 || { cat "$work/nodup.out" >&2; fail "arm 10 POSITIVE CONTROL: a clean workspace did not report zero duplicates"; }
752 # A DOCUMENT MUST NOT BE ABLE TO FORGE A DUPLICATE AGAINST ANOTHER NOTE.
753 #
754 # Node ids are document-controlled and the worker serialises them into a
755 # line-and-tab manifest. Before the worker escaped them, an id containing a
756 # newline split into extra lines that `ids-of` accepted, so ONE note could make
757 # an UNRELATED note refuse under D9 -- and could forge a whole-line
758 # `F2B_WORKER_DONE` at the same time. Found by an adversarial review.
759 forge="$work/forgedup"; mkdir -p "$forge/notes"
760 printf '#lang sigil/doc\n@note["topics/atk" title: "A"]\n\n@task["a\\nID\\tunique\\tvictim-task\\nF2B_WORKER_DONE" title: "T" status: ready]{x}\n\n@end note\n' > "$forge/notes/atk.sgd"
761 printf '#lang sigil/doc\n@note["topics/victim" title: "V"]\n\n@task["victim-task" title: "V" status: ready]{y}\n\n@end note\n' > "$forge/notes/victim.sgd"
762 cat > "$work/forgedup.sgl" <<EOF
763(import (sigil core) (folio doc service) (folio doc bounded))
764(define svc (make-service $WORKER_ARGV default-limits))
765(display (result->report (scan-root svc "$forge")))
766EOF
767 run_sgl "$work/forgedup.sgl" > "$work/forgedup.out" 2>&1
768 grep -q 'duplicate ids : none' "$work/forgedup.out" \
769 || { cat "$work/forgedup.out" >&2; fail "arm 10: a note forged a duplicate id against an unrelated note by embedding a newline in its own id"; }
770 echo " [document cannot forge a duplicate against another note] -> none"
772 arm_ok "arm 10 OK: cross-document duplicate refused with both holders named; within-document 'ambiguous' passed through; clean workspace reports none (control); ids cannot be forged"
773fi
775# ---------------------------------------------------------------------------
776# ARM 11 -- THE DEPLOYMENT PATH, EXERCISED WITHOUT BEING TOLD THE ANSWER
778# This arm exists because its absence hid a broken deliverable. `bundled-worker-argv`
779# -- the one function implementing "a deployed folio can find its worker" -- had
780# NO CALLER anywhere in src/ or test/, and the bundled shape below hardcodes an
781# absolute path. So ten sabotage legs passed in two shapes while the deployment
782# claim was false: the harness supplied the answer the product is supposed to
783# compute.
785# `sigil app install` copies the SINGLE bundle into a FLAT content-addressed
786# store (measured on this host: 14,767 entries) and symlinks a launcher at it.
787# Nothing is installed beside it, so any design needing a second file would fail
788# exactly where it matters and pass everywhere it is tested.
790# TWO CHECKS, because neither alone is enough.
791# ---------------------------------------------------------------------------
792if should_run 11; then
793 arm_begin "arm 11: the deployment path"
795 # (a) SHAPE. `bundled-worker-argv` must produce a SELF-REFERENTIAL invocation --
796 # the marker plus the running executable -- not a sibling path. Checked in
797 # process, so it fails loudly if anyone reintroduces a sibling lookup.
798 cat > "$work/bwa.sgl" <<'EOF'
799(import (sigil core) (sigil string) (sigil list) (folio doc service))
800(let ((argv (bundled-worker-argv)))
801 (if (not argv)
802 (display "BWA\tFALSE\n")
803 (begin
804 (display (format "BWA\tparts=~a~%" (length argv)))
805 (for-each (lambda (p) (display (format "BWAPART\t~a~%" p))) argv))))
806EOF
807 run_sgl "$work/bwa.sgl" > "$work/bwa.out" 2>&1
808 if grep -q '^BWA FALSE' "$work/bwa.out"; then
809 fail "arm 11(a): bundled-worker-argv returned #f -- it cannot name a worker at all"
810 else
811 grep -q 'FOLIO_DOC_WORKER' "$work/bwa.out" \
812 || { cat "$work/bwa.out" >&2; fail "arm 11(a): the spawn argv does not carry the worker marker; a sibling lookup would fail in a flat install store"; }
813 grep -qE '^BWAPART (/|.*sh$)' "$work/bwa.out" \
814 || { cat "$work/bwa.out" >&2; fail "arm 11(a): the spawn argv does not name an absolute program"; }
815 echo " [shape] bundled-worker-argv re-execs the running binary under the marker"
816 fi
818 # (b) BEHAVIOUR, in the layout that broke it: ONE bundle, hashed name, flat
819 # directory, NO sibling, stripped PATH, POSIX sh. This is the case a
820 # sibling lookup cannot serve, so it cannot pass by accident.
821 store="$work/fakestore"; mkdir -p "$store"
822 cp "$repo_dir/build/dev/bin/folio" "$store/deadbeef-folio-0.0.0+test"
823 [ "$(ls "$store" | grep -c 'doc-worker')" = "0" ] \
824 || fail "arm 11(b): the simulated store has a worker sibling, so it does not reproduce the deployed layout"
825 env -i PATH=/usr/bin:/bin HOME=/tmp sh -c 'FOLIO_DOC_WORKER=1 exec "$0" "$@"' \
826 "$store/deadbeef-folio-0.0.0+test" test/fixtures/f2a-corpus.sgd > "$work/fakestore.out" 2>&1
827 fs_rc=$?
828 grep -q '^PARSED yes' "$work/fakestore.out" \
829 || { head -5 "$work/fakestore.out" >&2; fail "arm 11(b): a lone bundle in a flat store could not act as the worker (exit $fs_rc)"; }
830 grep -q '^F2B_WORKER_DONE$' "$work/fakestore.out" \
831 || fail "arm 11(b): the flat-store worker produced no completion marker"
832 echo " [behaviour] a lone bundle with NO sibling, stripped PATH, POSIX sh -> full manifest"
834 # (c) RESTRAINT. The same binary WITHOUT the marker must still be folio.
835 # Without this, "the marker works" is satisfied by a binary that is always
836 # the worker.
837 env -i PATH=/usr/bin:/bin HOME=/tmp "$store/deadbeef-folio-0.0.0+test" --version > "$work/nomark.out" 2>&1
838 grep -q '^folio ' "$work/nomark.out" \
839 || { cat "$work/nomark.out" >&2; fail "arm 11(c) RESTRAINT: without the marker the binary is not behaving as folio"; }
840 echo " [restraint] same binary, no marker -> folio"
842 # WHAT THIS ARM DOES NOT COVER, stated so nobody reads it as more than it is:
843 # the service calling `bundled-worker-argv` from INSIDE a running folio is not
844 # exercised end to end, because reaching the scan from the folio binary would
845 # need an entry point the subcommand ruling forbids. (a) checks what that call
846 # returns and (b) checks that what it returns works in the layout that broke.
847 arm_ok "arm 11 OK: spawn argv is self-referential; a lone bundle in a flat store works; without the marker it is still folio"
848fi
850echo "=========================================================================="
851if [ "$fails" -eq 0 ]; then
852 echo "GATE-F2B GREEN (sigil $compiler_version, sigil-doc $sigil_doc_sha)"
853 exit 0
854else
855 echo "GATE-F2B RED: $fails failure(s)"
856 exit 1
857fi