diff --git a/ci/spec22-gems/README.md b/ci/spec22-gems/README.md index 5423959..77123d1 100644 --- a/ci/spec22-gems/README.md +++ b/ci/spec22-gems/README.md @@ -158,7 +158,10 @@ but no state. `run-msys.sh` is the same acceptance on windows (spec 22 §8's last row: the suite green with the gem gone on **every** published platform). Same -fixtures, same four jailed legs, same pinned PROBE lines — but it builds +fixtures, the same four jailed proof legs plus a fifth forensic one +(`sassc-matrix` — the incident-13 diag sheet on a pristine loader: the +sha256 legs, the ffi solo loads, the spelling × flag raw-loader matrix; +never gates), same pinned PROBE lines — but it builds nothing: the runtime arrives as the factory's CI artifacts and the press/extract tooling is the published windows `tfs` CLI. On msys the POSIX harness's roll → link-unit → factory-build chain would re-run the diff --git a/ci/spec22-gems/fixtures/payload-manifest.yaml b/ci/spec22-gems/fixtures/payload-manifest.yaml index a7ab13d..467ab17 100644 --- a/ci/spec22-gems/fixtures/payload-manifest.yaml +++ b/ci/spec22-gems/fixtures/payload-manifest.yaml @@ -4,6 +4,11 @@ # excludes /__tpkg__/; blob_sha256 stays as authored — advisory producer # provenance, never a verification input for an embedded manifest). # +# The library_aliases: block (spec 03 §2.5) is also stamped at press time +# — by run-msys.sh, one entry per vendored closure DLL, a DERIVED set +# (never hand-listed here; POSIX run.sh stamps nothing — the bare-name +# rule is a windows contract). +# # The materialize: block is the spec 22 §4 class-R declaration under # test: main.scss and the partial it @imports are files libsass's OWN # C++ importer must fopen() on a raw HOST path (the interpreter's diff --git a/ci/spec22-gems/fixtures/probe.rb b/ci/spec22-gems/fixtures/probe.rb index f525a68..9e2415f 100644 --- a/ci/spec22-gems/fixtures/probe.rb +++ b/ci/spec22-gems/fixtures/probe.rb @@ -33,6 +33,11 @@ # with the pinned class-R signature. Expected RED — # the mechanism oracle: a GREEN here means the flip # came from somewhere other than materialize:. +# sassc-matrix — pure forensics, never gates: the incident-13 diag +# sheet (sha256 legs, ffi solo loads, the spelling × +# flag raw-loader matrix) run WITHOUT the require +# attempt, so the loader starts pristine. windows +# only (POSIX prints a skip line). Expected GREEN. # # Every invocation prints the instrumentation line # `PROBE gem-loaded yes|no` first — whether a tebako-runtime gem got @@ -150,7 +155,10 @@ def static_fetch # match IS this payload's extraction — anything else (no export, no match, # several matches) is a loud probe failure, never a silent fallthrough. def materialized_styles_dir - cache = ENV["TEBAKO_EXEC_CACHE"].to_s + # The driver exports the HOST spelling (backslashes on windows), and + # Dir.glob treats backslash as an escape there — glob with the + # forward-slash spelling (ruby accepts it everywhere on windows). + cache = ENV["TEBAKO_EXEC_CACHE"].to_s.tr('\\', '/') if cache.empty? puts "PROBE sassc-partial fail TEBAKO_EXEC_CACHE is not exported (the driver predates class R?)" exit 1 @@ -176,36 +184,33 @@ def sassc_partial(styles) ).render end -# Incident 13 round 7 diagnostics. Round 6 (the first run carrying the -# ffi bisect) proved two things and broke one: the closure walk's answers -# match ground truth byte-for-byte (the pressed payload image, extracted -# on macOS: all 16 imports of libsass.so match llvm-objdump's read of the -# real import tables; every vendored sibling is a valid coff-x86-64 PE), -# and the ffi failure is the OS's own answer (the shim's covered route -# forwards LOAD_WITH_ALTERED_SEARCH_PATH — dln_c_dlmap_msys.patch forces -# 0x8 when the caller named no LOAD_LIBRARY_SEARCH_* order). What broke: -# FFI::DynamicLibrary.load_library went private in ffi 1.17, so the -# round-6 bisect died NoMethodError on its first leg and no dep-load -# verdict ever printed. +# Incident 13 round 8 diagnostics. Round 7 answered the byte and bind +# questions: every vendored module's sha256 through ruby's patched IO +# matched the image-extracted constants (no windows backend read bug), +# the closure walk materialized the siblings co-dir, and each module — +# libsass.so included — SOLO-LOADS through ffi's DynamicLibrary.open once +# its siblings are resident in the process. Yet ffi_lib's load of the +# in-image libsass.so spelling still 126'd at require time, and the ext/ +# fallback's error 5 is the jail's EACCES synthesis on a file the image +# never held (the mkmf stub is not installed there — red herring). So +# the failure lives in the DEPENDENCY search of ffi's raw LoadLibraryExA +# (LOAD_WITH_ALTERED_SEARCH_PATH on the non-relative spelling), not in +# the bytes and not in the main module's own open. # -# What has never been measured ON THE RUNNER: -# (a) the bytes the WINDOWS backend streams out — a windows-only backend -# read bug is poison the macOS extraction cannot show, so sha256 each -# vendored module through ruby's patched IO and compare against the -# image-extracted constants (the vendored DLLs are copied binaries — -# byte-stable across runs; libsass.so is compiled per run by gem -# install, so its comparison is informational, never a verdict); -# (b) each vendored sibling's OWN OS bind — the 126 names "a dep", -# never which; -# (c) the search-order semantics themselves: a fiddle-driven flag matrix -# against the materialized HOST spelling. The host spelling is not a -# covered path, so the shim passes it through byte-identical and -# fiddle drives the RAW loader; the default-order leg is the negative -# control (the standard order never searches the DLL's own dir, so -# 126 there is documented behavior — a SUCCESS there rewrites the -# model). +# Round 8's questions: +# (a) the spelling × flag matrix (sassc_raw_matrix) — the A: legs name +# the covered route's answer per flag; the host legs measure the +# bare OS loader; the 0x0 leg is the default-order negative control; +# (b) the already-loaded-table discriminator (sassc_ffi_load_legs runs +# libsass.so before AND after its siblings); +# (c) the DECLARED answer (the candidate fix): the payload manifests +# stamp library_aliases: for the vendored closure at press time +# (spec 03 §2.5), so the driver boot-materializes the siblings and +# PATH-leads their dir — the spec 22 §2.1 raw-surface mechanism, +# exercised end to end. ffi_lib's own A: spelling keeps its route; +# the alias carries the bare-name IMPORTS the PE closure lists. # Never gates: the original LoadError re-raises after the verdicts, and -# no leg may kill the bisect (every leg rescues StandardError). +# no leg may kill the sheet (every leg rescues LoadError/StandardError). # sha256 + byte counts of the three vendored modules, extracted from the # round-6 run's pressed payload image (probe-gems-4.0.6.tfs) on macOS via @@ -217,12 +222,17 @@ def sassc_partial(styles) }.freeze # The vendored modules' in-image paths, keyed by basename; empty when the -# sassc spec never activated (the require died before rubygems recorded it). +# sassc spec is not discoverable. find_by_name, never loaded_specs — the +# sassc-matrix leg runs this sheet WITHOUT a require attempt, so nothing +# has activated the spec; the bisect path (post-LoadError) finds the same +# record either way. def sassc_module_paths - spec = Gem.loaded_specs["sassc"] + spec = Gem::Specification.find_by_name("sassc") return {} if spec.nil? native_dir = File.join(spec.gem_dir, "lib", "sassc") SASSC_MODULE_WANTS.keys.to_h { |mod| [mod, File.join(native_dir, mod)] } +rescue LoadError, StandardError + {} # a broken gemhome index must not take the sheet down with it end def sassc_sha256_legs @@ -239,79 +249,111 @@ def sassc_sha256_legs end # Two host-surface controls by bare name (a stock OS module; an -# api-ms-win-crt contract), then each vendored sibling individually, then -# the top module — all through ffi's public DynamicLibrary.open, i.e. the -# same covered route the failing ffi_lib took. A success stays loaded and -# would poison later legs, so every success is freed at once. +# api-ms-win-crt contract), then the vendored modules — all through +# ffi's public DynamicLibrary.open, i.e. the same covered route the +# failing ffi_lib took (ffi's win32 dl_open ignores the RTLD flags and +# binds LoadLibraryExA with LOAD_WITH_ALTERED_SEARCH_PATH for every +# non-relative spelling — DynamicLibrary.c). libsass.so runs FIRST +# (nothing vendored resident — the already-loaded-table discriminator), +# the siblings next, then libsass.so again: a first-fail/second-ok pair +# names the dep-search class, and a success stays loaded (ffi 1.17's +# DynamicLibrary has no public free — the round-7 `.free` legs were +# NoMethodError noise; the raw matrix below owns the freed-legs +# questions). def sassc_ffi_load_legs legs = { "ADVAPI32.dll" => "ADVAPI32.dll", "api-ms-win-crt-runtime-l1-1-0.dll" => "api-ms-win-crt-runtime-l1-1-0.dll" } - sassc_module_paths.each { |label, path| legs[label] = path } + paths = sassc_module_paths + legs["libsass.so"] = paths["libsass.so"] if paths["libsass.so"] + %w[libwinpthread-1.dll libgcc_s_seh-1.dll].each { |mod| legs[mod] = paths[mod] if paths[mod] } + legs["libsass.so:again"] = paths["libsass.so"] if paths["libsass.so"] legs.each do |label, spell| - lib = FFI::DynamicLibrary.open(spell, FFI::DynamicLibrary::RTLD_LAZY) + FFI::DynamicLibrary.open(spell, FFI::DynamicLibrary::RTLD_LAZY) puts "PROBE-DIAG dep-load #{label} ok" - lib.free - rescue StandardError => le + rescue LoadError, StandardError => le puts "PROBE-DIAG dep-load #{label} fail #{le.message.lines.first.to_s.strip}" end end -# The raw-loader flag matrix against the materialized HOST spelling. The -# dlmap cache mirrors the memfs tree under TEBAKO_EXEC_CACHE/tebako-dl-*; -# the host spelling is not a covered path, so the shim passes it through -# byte-identical and these legs measure the OS loader alone. -def sassc_fiddle_matrix - cache = ENV["TEBAKO_EXEC_CACHE"].to_s - if cache.empty? - puts "PROBE-DIAG fiddle skipped (TEBAKO_EXEC_CACHE unset)" +# The raw-loader matrix — spelling × flags — driven through FFI-bound +# kernel32 calls. Round 7 drove this through fiddle; fiddle left the +# default-gem set in ruby 4.0 ("fiddle is not part of the default gems" +# — the round-7 sheet died on the require, a LoadError StandardError +# never rescues). ffi is already on the sheet path (sassc's native.rb +# requires it before the failing ffi_lib) and its DynamicLibrary is the +# very surface under test, so the matrix needs no new dependency. +# +# The A: (VFS) legs are the covered-route question — does the shim serve +# the loader for THIS spelling/flag pair; the host legs measure the bare +# OS loader (the shim passes the materialized spelling through +# byte-identical). 0x0 (default order) is the negative control: the +# standard order never searches the DLL's own dir, so a 126 there is +# documented behavior and a SUCCESS rewrites the model. 0x8 is ffi's own +# binding (DynamicLibrary.c: LoadLibraryExA with LOAD_WITH_ALTERED_ +# SEARCH_PATH for non-relative names). Every success is FreeLibrary'd at +# once so no leg poisons the next through the already-loaded table. +def sassc_raw_matrix + unless Gem.win_platform? + puts "PROBE-DIAG raw-matrix skipped (posix)" return end - require "fiddle" - kernel32 = Fiddle.dlopen("kernel32") - load_ex_a = Fiddle::Function.new(kernel32["LoadLibraryExA"], - [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG], - Fiddle::TYPE_VOIDP) - free_lib = Fiddle::Function.new(kernel32["FreeLibrary"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_LONG) - last_err = Fiddle::Function.new(kernel32["GetLastError"], [], Fiddle::TYPE_LONG) - host_dir = Dir.glob(File.join(cache, "tebako-dl-*", "A_", "probe", "gemhome", "gems", - "sassc-2.4.0", "lib", "sassc")).first - if host_dir.nil? - puts "PROBE-DIAG fiddle skipped (no tebako-dl cache under #{cache})" - return + k32 = Module.new do + extend FFI::Library + ffi_lib "kernel32" + attach_function :load_ex, :LoadLibraryExA, %i[string pointer ulong], :pointer + attach_function :free_lib, :FreeLibrary, [:pointer], :int + attach_function :last_err, :GetLastError, [], :ulong + end + paths = sassc_module_paths + # The glob spelling, see materialized_styles_dir (backslash escapes). + cache = ENV["TEBAKO_EXEC_CACHE"].to_s.tr('\\', '/') + host_dir = cache.empty? ? nil : Dir.glob(File.join(cache, "tebako-dl-*", "A_", "probe", "gemhome", "gems", + "sassc-*", "lib", "sassc")).first + legs = [] + [["default-order-NEGCTL", 0x0], ["altered", 0x8], ["search-default+dll-dir", 0x1100]].each do |fname, fl| + legs << ["libsass.so:vfs:#{fname}", paths["libsass.so"], fl] + legs << ["libsass.so:host:#{fname}", host_dir && File.join(host_dir, "libsass.so"), fl] end - # libsass's three flag legs first (the search-order question), then the - # siblings' solo binds under the ffi-equivalent flag (the which-dep - # question). A succeeded load is freed immediately so no leg poisons - # the next via the loader's already-loaded table. - matrix = [["libsass.so:default-order-NEGCTL", File.join(host_dir, "libsass.so"), 0x0], - ["libsass.so:altered", File.join(host_dir, "libsass.so"), 0x8], - ["libsass.so:search-default+dll-dir", File.join(host_dir, "libsass.so"), 0x1100]] - SASSC_MODULE_WANTS.each_key { |mod| matrix << ["#{mod}:altered", File.join(host_dir, mod), 0x8] } - matrix.each do |label, host_path, fl| - unless File.exist?(host_path) - puts "PROBE-DIAG fiddle-load #{label} missing-on-host #{host_path}" + %w[libwinpthread-1.dll libgcc_s_seh-1.dll].each do |mod| + legs << ["#{mod}:vfs:altered", paths[mod], 0x8] + legs << ["#{mod}:host:altered", host_dir && File.join(host_dir, mod), 0x8] + end + legs.each do |label, spell, fl| + if spell.nil? + puts "PROBE-DIAG raw-load #{label} skipped (no spelling)" next end - h = load_ex_a.call(Fiddle::Pointer[host_path], nil, fl) - if h.nil? || h.zero? - puts "PROBE-DIAG fiddle-load #{label} fail os-err=#{last_err.call}" + h = k32.load_ex(spell, nil, fl) + if h.nil? || h.null? + puts "PROBE-DIAG raw-load #{label} fail os-err=#{k32.last_err}" else - puts "PROBE-DIAG fiddle-load #{label} ok" - free_lib.call(h) + puts "PROBE-DIAG raw-load #{label} ok" + k32.free_lib(h) end - rescue StandardError => fe - puts "PROBE-DIAG fiddle-load #{label} error #{fe.class}: #{fe.message.lines.first.to_s.strip}" + rescue LoadError, StandardError => fe + puts "PROBE-DIAG raw-load #{label} error #{fe.class}: #{fe.message.lines.first.to_s.strip}" + end +end + +# The diag sheet, shared by the bisect rescue (post-LoadError state) and +# the sassc-matrix leg (pristine state). ffi must load for the sheet to +# exist at all — name it and bail when even that fails. +def run_sassc_sheet + begin + require "ffi" + rescue LoadError => le + puts "PROBE-DIAG sheet aborted: ffi itself does not load (#{le.message.lines.first.to_s.strip})" + return end -rescue StandardError => fe - puts "PROBE-DIAG fiddle aborted #{fe.class}: #{fe.message.lines.first.to_s.strip}" + sassc_sha256_legs + sassc_ffi_load_legs + sassc_raw_matrix end def require_sassc_with_bisect require "sassc" rescue LoadError => e - sassc_sha256_legs - sassc_ffi_load_legs - sassc_fiddle_matrix + run_sassc_sheet raise e end @@ -377,7 +419,18 @@ def require_sassc_with_bisect rescue Exception => e # rubocop:disable Lint/RescueException -- the probe must see every failure mode puts "PROBE sassc-partial-unmaterialized expected-fail #{e.class}: #{e.message.lines.first.to_s.strip}" end +when "sassc-matrix" + # Forensics WITHOUT the require attempt (pristine loader state): the + # full diag sheet, never gates. windows-only — the raw-loader matrix + # measures the windows loader's own semantics; POSIX has no such + # question (dlopen serves the covered route directly). + if Gem.win_platform? + run_sassc_sheet + puts "PROBE sassc-matrix done" + else + puts "PROBE sassc-matrix skipped (posix)" + end else - warn "probe.rb: unknown leg #{ARGV[0].inspect} (sinatra-fixed|sinatra-unfixed|sassc|sassc-unmaterialized)" + warn "probe.rb: unknown leg #{ARGV[0].inspect} (sinatra-fixed|sinatra-unfixed|sassc|sassc-unmaterialized|sassc-matrix)" exit 64 end diff --git a/ci/spec22-gems/run-msys.sh b/ci/spec22-gems/run-msys.sh index 3af615e..11a2508 100755 --- a/ci/spec22-gems/run-msys.sh +++ b/ci/spec22-gems/run-msys.sh @@ -3,8 +3,9 @@ # The msys port of run.sh (spec 22 §8's last acceptance row: the v2 # dogfood suite green with the gem gone on EVERY published platform). # Runs the same four jailed proof legs against the same fixtures and -# pins the same PROBE lines (README.md), with the gem-loaded canary -# pinned `no`. +# pins the same PROBE lines (README.md) — plus a fifth forensic leg +# (sassc-matrix: the incident-13 diag sheet on a pristine loader, never +# gates) — with the gem-loaded canary pinned `no`. # # Unlike run.sh this script BUILDS NOTHING: the runtime (exe + ruby DLL # + env image) arrives as the factory's CI build-leg artifact and the @@ -428,16 +429,48 @@ if [ ! -f "$PAYLOAD_IMG" ] || [ ! -f "$PAYLOAD_IMG_NOMAT" ] \ rm -f "$PROBE_TREE/probe/payload-manifest.yaml" \ "$PROBE_TREE/probe/payload-manifest-unmaterialized.yaml" cp -R "$GEMHOME" "$PROBE_TREE/probe/gemhome" + # Incident 13 round 8: declare the vendored closure as library_aliases + # (spec 03 §2.5) so the driver boot-materializes the vendored DLLs and + # PATH-leads their dirs — the spec 22 §2.1 raw-surface answer. ffi's + # LoadLibraryExA on libsass.so's IN-IMAGE spelling keeps its own route; + # the aliases carry the bare-name IMPORTS the PE closure lists (round 7 + # proved the bytes present and byte-identical, the siblings + # solo-loadable, and the require-time 126 the loader's dep-search + # answer). The set is DERIVED (the same *.dll enumeration the vendoring + # above produced), never hardcoded; names dedupe within the image (a + # duplicate alias name fails the manifest parse by design). Stamped on + # BOTH manifests: the aliases are orthogonal to the materialize: + # negative oracle (the sassc-unmaterialized leg still requires sassc — + # only the partial read is the oracle). + stamp_library_aliases() { + local manifest="$1" dll name rel stamped=0 + local declared=" " + local gem_dir="$PROBE_TREE/probe/gemhome/gems/sassc-$SASSC_VERSION" + for dll in $(find "$gem_dir" -name '*.dll' | sort); do + name="$(basename "$dll")" + case "$declared" in *" $name "*) continue ;; esac + declared="$declared$name " + rel="${dll#"$PROBE_TREE"}" + if [ "$stamped" -eq 0 ]; then printf 'library_aliases:\n' >> "$manifest"; stamped=1; fi + printf ' - name: %s\n path: %s\n' "$name" "$rel" >> "$manifest" + done + } cp "$SELF_DIR/fixtures/payload-manifest.yaml" "$PROBE_TREE/__tpkg__/manifest.yaml" + stamp_library_aliases "$PROBE_TREE/__tpkg__/manifest.yaml" rm -f "$PAYLOAD_IMG" "$TFS_CLI" mkimage --format dwarfs "$(w "$PROBE_TREE")" --output "$(w "$PAYLOAD_IMG")" >/dev/null cp "$SELF_DIR/fixtures/payload-manifest-unmaterialized.yaml" "$PROBE_TREE/__tpkg__/manifest.yaml" + stamp_library_aliases "$PROBE_TREE/__tpkg__/manifest.yaml" rm -f "$PAYLOAD_IMG_NOMAT" "$TFS_CLI" mkimage --format dwarfs "$(w "$PROBE_TREE")" --output "$(w "$PAYLOAD_IMG_NOMAT")" >/dev/null "$TFS_CLI" cat "$(w "$PAYLOAD_IMG")" /__tpkg__/manifest.yaml | grep -q "^materialize:" \ || die "the pressed image lost the materialize: key (spec 22 §4)" "$TFS_CLI" cat "$(w "$PAYLOAD_IMG_NOMAT")" /__tpkg__/manifest.yaml | grep -q "^materialize:" \ && die "the negative-oracle image carries materialize: — the oracle would prove nothing" + "$TFS_CLI" cat "$(w "$PAYLOAD_IMG")" /__tpkg__/manifest.yaml | grep -q "^library_aliases:" \ + || die "the pressed image lost the library_aliases: key (spec 22 §2.1)" + "$TFS_CLI" cat "$(w "$PAYLOAD_IMG_NOMAT")" /__tpkg__/manifest.yaml | grep -q "^library_aliases:" \ + || die "the negative-oracle image lost the library_aliases: key (spec 22 §2.1)" # The gemhome tree must survive the press verbatim — the proof legs # discover gems ONLY from the image (incident 12: distinguish a # press-side drop from a runtime discovery miss before the legs run). @@ -511,11 +544,15 @@ run_probe() { } step "jailed probe runs (TEBAKO_JAIL=deny;:/host-scratch:rw)" -st_fixed=0; st_unfixed=0; st_sassc=0; st_sassc_nomat=0 +st_fixed=0; st_unfixed=0; st_sassc=0; st_sassc_nomat=0; st_matrix=0 run_probe sinatra-fixed || st_fixed=$? run_probe sinatra-unfixed || st_unfixed=$? run_probe sassc || st_sassc=$? run_probe sassc-unmaterialized "$PAYLOAD_IMG_NOMAT" || st_sassc_nomat=$? +# The incident-13 forensic sheet on a pristine loader (no require attempt +# first — the bisect legs run post-LoadError by construction). Its +# PROBE-DIAG lines carry the verdicts; the leg itself must only complete. +run_probe sassc-matrix || st_matrix=$? gem_loaded="$(grep -m1 -oE '^PROBE gem-loaded (yes|no)' "$SCRATCH/proof-sinatra-fixed.log" | awk '{print $3}')" [ -n "$gem_loaded" ] || die "gem-loaded instrumentation line missing" @@ -523,7 +560,7 @@ gem_loaded="$(grep -m1 -oE '^PROBE gem-loaded (yes|no)' "$SCRATCH/proof-sinatra- # means the gem crept back into the env image (or a boot-time require # returned) — same pin as POSIX. [ "$gem_loaded" = "no" ] || die "gem-loaded must be no post-M2, got $gem_loaded" -for leg in sinatra-unfixed sassc sassc-unmaterialized; do +for leg in sinatra-unfixed sassc sassc-unmaterialized sassc-matrix; do other="$(grep -m1 -oE '^PROBE gem-loaded (yes|no)' "$SCRATCH/proof-$leg.log" | awk '{print $3}')" [ "$other" = "$gem_loaded" ] || die "gem-loaded disagree across legs ($gem_loaded vs $other in $leg)" done @@ -539,5 +576,8 @@ grep -q "^PROBE sinatra-unfixed ok status=200 " "$SCRATCH/proof-sinatra-unfixed. [ "$st_sassc_nomat" -eq 0 ] || die "sassc-unmaterialized leg exit $st_sassc_nomat" grep -qF "PROBE sassc-partial-unmaterialized expected-fail $SASSC_PARTIAL_PIN" "$SCRATCH/proof-sassc-unmaterialized.log" \ || die "sassc-partial-unmaterialized leg (expected the pinned class-R failure signature)" +[ "$st_matrix" -eq 0 ] || die "sassc-matrix leg exit $st_matrix" +grep -q "^PROBE sassc-matrix done" "$SCRATCH/proof-sassc-matrix.log" \ + || die "sassc-matrix leg (no done line — the sheet aborted)" echo "SPEC22-GEMS-MSYS-ACCEPTANCE-OK $VERSION ($RUNTIME_EXE; sinatra $SINATRA_VERSION, sassc $SASSC_VERSION, no tebako-runtime gem)"