From 3ad51711b4ef7d572ff66a1166dab8a18a15c080 Mon Sep 17 00:00:00 2001 From: Isaac Freeman Date: Tue, 1 Sep 2026 04:40:49 -0400 Subject: [PATCH 1/2] Replace Bash test harness with YAML runner --- README.md | 15 + test.rb | 314 ++++++++++++++ test.sh | 337 ---------------- tests.yml | 1167 +++++++++++++++++++++++++++++++++++++++++++++++++++++ verify.sh | 7 +- 5 files changed, 1500 insertions(+), 340 deletions(-) create mode 100755 test.rb delete mode 100755 test.sh create mode 100644 tests.yml diff --git a/README.md b/README.md index 36d8d21..c723a67 100644 --- a/README.md +++ b/README.md @@ -73,3 +73,18 @@ character `a`. It is up to the caller to interpret or otherwise handle escape sequences in the returned text. + +### Testing + +Parser test cases live in `tests.yml` and are run by `test.rb`. Each case has a +lower-case hyphenated name, JSON input and transport, query terms, expected +output and status, and a user-facing description. Run the catalog against a +specific implementation with: + + ./test.rb -s ./bj.sh + +Optional timing cases require `citylots.json` and are included with `-t`. +Repository-level verification, including every generated implementation under +the default locale and `LC_ALL=C`, is run with: + + make verify diff --git a/test.rb b/test.rb new file mode 100755 index 0000000..51ee47b --- /dev/null +++ b/test.rb @@ -0,0 +1,314 @@ +#!/usr/bin/env ruby + +require 'open3' +require 'optparse' +require 'tempfile' +require 'yaml' + +NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ +QUERY_DRIVER = <<~'BASH' + . "$1" || exit 126 + shift + [[ $- != *u* ]] || trap '[[ $- == *u* ]] || exit 125' EXIT + bj "$@" +BASH + +# A large fixture cannot cross exec's argument-size limit, so Bash must turn +# this one file into a function argument after the process starts. +FILE_ARGUMENT_DRIVER = <<~'BASH' + . "$1" || exit 126 + json_file=$2 + shift 2 + [[ $- != *u* ]] || trap '[[ $- == *u* ]] || exit 125' EXIT + bj "$(<"$json_file")" "$@" +BASH + +class CatalogError < StandardError; end + +class Catalog + attr_reader :report, :tests + + def initialize(path, include_timing: false) + @path = File.expand_path(path) + document = YAML.safe_load(File.read(@path), aliases: false) + validate_document(document) + @report = document.fetch('report') + @fixtures = document.fetch('fixtures', {}) + all_tests = document.fetch('tests') + validate_tests(all_tests) + @tests = all_tests.reject { |test| timing?(test) && !include_timing } + @skipped = all_tests.length - @tests.length + rescue Errno::ENOENT, Psych::SyntaxError => error + raise CatalogError, error.message + end + + attr_reader :skipped + + def input_for(test) + input = test.fetch('input') + if input.key?('json') + [input.fetch('transport', 'argument'), input.fetch('json')] + elsif input.key?('fixture') + fixture = input.fetch('fixture') + raise CatalogError, "#{test['name']}: unknown fixture #{fixture.inspect}" \ + unless @fixtures.key?(fixture) + [input.fetch('transport', 'argument'), @fixtures.fetch(fixture)] + else + path = File.expand_path(input.fetch('file'), File.dirname(@path)) + ["file-#{input.fetch('transport', 'argument')}", path] + end + end + + private + + def validate_document(document) + raise CatalogError, 'catalog must contain a YAML mapping' unless document.is_a?(Hash) + raise CatalogError, 'unsupported catalog version' unless document['version'] == 1 + raise CatalogError, 'report must contain success and failure strings' unless + document['report'].is_a?(Hash) && + %w[success failure].all? { |key| document['report'][key].is_a?(String) } + raise CatalogError, 'fixtures must be a mapping' unless + document.fetch('fixtures', {}).is_a?(Hash) + raise CatalogError, 'fixture values must be strings' unless + document.fetch('fixtures', {}).values.all? { |value| value.is_a?(String) } + raise CatalogError, 'tests must be an array' unless document['tests'].is_a?(Array) + end + + def validate_tests(tests) + names = {} + tests.each_with_index do |test, index| + raise CatalogError, "test #{index + 1} must be a mapping" unless test.is_a?(Hash) + name = test['name'] + raise CatalogError, "test #{index + 1} has an invalid name" unless + name.is_a?(String) && NAME_PATTERN.match?(name) + raise CatalogError, "duplicate test name: #{name}" if names[name] + names[name] = true + + raise CatalogError, "#{name}: description must be a string" unless + test['description'].is_a?(String) + raise CatalogError, "#{name}: query must be an array of strings" unless + test['query'].is_a?(Array) && test['query'].all? { |term| term.is_a?(String) } + validate_input(name, test['input']) + validate_expected(name, test) + validate_shell(name, test.fetch('shell', {})) + raise CatalogError, "#{name}: tags must be an array of strings" unless + test.fetch('tags', []).is_a?(Array) && + test.fetch('tags', []).all? { |tag| tag.is_a?(String) } + end + end + + def validate_input(name, input) + raise CatalogError, "#{name}: input must be a mapping" unless input.is_a?(Hash) + sources = %w[json fixture file].select { |key| input.key?(key) } + raise CatalogError, "#{name}: input needs exactly one source" unless sources.length == 1 + source = sources.first + raise CatalogError, "#{name}: input #{source} must be a string" unless + input[source].is_a?(String) + if source == 'fixture' && !@fixtures.key?(input[source]) + raise CatalogError, "#{name}: unknown fixture #{input[source].inspect}" + end + transport = input.fetch('transport', 'argument') + raise CatalogError, "#{name}: transport must be argument or stdin" unless + %w[argument stdin].include?(transport) + if input.key?('trailing-newline') && transport != 'stdin' + raise CatalogError, "#{name}: trailing-newline requires stdin transport" + end + if input.key?('trailing-newline') && ![true, false].include?(input['trailing-newline']) + raise CatalogError, "#{name}: trailing-newline must be true or false" + end + end + + def validate_expected(name, test) + expected = test['expected'] + raise CatalogError, "#{name}: expected must be a mapping" unless expected.is_a?(Hash) + raise CatalogError, "#{name}: expected status must be an integer" unless + expected['status'].is_a?(Integer) + operation = test.fetch('operation', 'query') + raise CatalogError, "#{name}: unsupported operation #{operation.inspect}" unless + %w[query iterate].include?(operation) + output = expected['output'] + valid_output = operation == 'iterate' ? + output.is_a?(Array) && output.all? { |item| item.is_a?(String) } : + output.is_a?(String) + raise CatalogError, "#{name}: expected output has the wrong type" unless valid_output + end + + def validate_shell(name, shell) + raise CatalogError, "#{name}: shell must be a mapping" unless shell.is_a?(Hash) + return unless shell.key?('nounset') && ![true, false].include?(shell['nounset']) + raise CatalogError, "#{name}: shell nounset must be true or false" + end + + def timing?(test) + test.fetch('tags', []).include?('timing') + end +end + +class Runner + def initialize(catalog, source, drivers) + @catalog = catalog + @source = File.expand_path(source) + @drivers = drivers + end + + def run + passed = 0 + failed = 0 + + puts "Testing #{@source}" + @catalog.tests.each do |test| + result = run_test(test) + success = result[:output] == test.dig('expected', 'output') && + result[:status] == test.dig('expected', 'status') + success ? passed += 1 : failed += 1 + report(test, result, success) + rescue StandardError => error + failed += 1 + report_error(test, error) + end + + total = passed + failed + puts "Summary: #{total} run, #{passed} passed, #{failed} failed, " \ + + "#{@catalog.skipped} skipped" + failed.zero? ? 0 : 1 + end + + private + + def run_test(test) + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + output, status, stderr = if test.fetch('operation', 'query') == 'iterate' + run_iteration(test) + else + run_query(test) + end + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + {output: output, status: status, stderr: stderr, elapsed: elapsed} + end + + def run_iteration(test) + output = [] + stderr = [] + index = 0 + expected_count = test.dig('expected', 'output').length + loop do + actual, status, error = invoke(test, test.fetch('query') + [index.to_s]) + stderr << error unless error.empty? + return [output, status, stderr.join] unless status.zero? + output << actual + index += 1 + return [output, status, stderr.join] if index > expected_count + end + end + + def run_query(test) + output, status, stderr = invoke(test, test.fetch('query')) + [output, status, stderr] + end + + def invoke(test, query) + transport, input = @catalog.input_for(test) + command = ['bash'] + command << '-u' if test.dig('shell', 'nounset') + stdin_data = '' + stdin_file = nil + + case transport + when 'argument' + command.concat([@drivers.fetch(:query), @source, input, *query]) + when 'stdin' + command.concat([@drivers.fetch(:query), @source, '-', *query]) + stdin_data = input + (test.dig('input', 'trailing-newline') ? "\n" : '') + when 'file-stdin' + ensure_readable(input) + command.concat([@drivers.fetch(:query), @source, '-', *query]) + stdin_file = input + when 'file-argument' + ensure_readable(input) + command.concat([@drivers.fetch(:file_argument), @source, input, *query]) + else + raise "unknown input transport: #{transport}" + end + + stdout, stderr, process = capture( + command, stdin_data: stdin_data, stdin_file: stdin_file + ) + stdout.force_encoding(Encoding::UTF_8) + stderr.force_encoding(Encoding::UTF_8) + status = process.exitstatus || 128 + process.termsig + [stdout, status, stderr] + end + + def capture(command, stdin_data:, stdin_file:) + Open3.popen3(*command) do |stdin, stdout, stderr, process| + stdout_reader = Thread.new { stdout.read } + stderr_reader = Thread.new { stderr.read } + + begin + if stdin_file + File.open(stdin_file, 'rb') { |file| IO.copy_stream(file, stdin) } + else + stdin.write(stdin_data) + end + rescue Errno::EPIPE + # The implementation exited without consuming all of its input. + ensure + stdin.close + end + + [stdout_reader.value, stderr_reader.value, process.value] + end + end + + def ensure_readable(path) + raise "input file is not readable: #{path}" unless File.readable?(path) + end + + def report(test, result, success) + key = success ? 'success' : 'failure' + message = format( + @catalog.report.fetch(key), + name: test.fetch('name'), description: test.fetch('description') + ) + expected = test.fetch('expected') + line = "#{message} | status actual=#{result[:status]} " \ + + "expected=#{expected.fetch('status')} | output actual=" \ + + "#{result[:output].inspect} expected=#{expected.fetch('output').inspect}" + line += format(' | %.3fs', result[:elapsed]) if test.fetch('tags', []).include?('timing') + puts line + puts " stderr: #{result[:stderr].inspect}" unless result[:stderr].empty? + end + + def report_error(test, error) + name = test.is_a?(Hash) ? test.fetch('name', 'unknown-test') : 'unknown-test' + description = test.is_a?(Hash) ? test.fetch('description', error.message) : error.message + message = format(@catalog.report.fetch('failure'), name: name, description: description) + puts "#{message} | runner error: #{error.message}" + end +end + +options = {catalog: File.join(__dir__, 'tests.yml'), source: File.join(__dir__, 'bj.sh')} +parser = OptionParser.new do |opts| + opts.banner = 'Usage: test.rb [-s PATH] [-f PATH] [-t]' + opts.on('-s', '--source PATH', 'implementation to source') { |path| options[:source] = path } + opts.on('-f', '--file PATH', 'YAML test catalog') { |path| options[:catalog] = path } + opts.on('-t', '--timing', 'include optional timing tests') { options[:timing] = true } +end +parser.parse! + +begin + catalog = Catalog.new(options[:catalog], include_timing: options[:timing]) + Tempfile.create(['bj-query-driver', '.sh']) do |query_driver| + query_driver.write(QUERY_DRIVER) + query_driver.flush + Tempfile.create(['bj-file-argument-driver', '.sh']) do |file_argument_driver| + file_argument_driver.write(FILE_ARGUMENT_DRIVER) + file_argument_driver.flush + drivers = {query: query_driver.path, file_argument: file_argument_driver.path} + exit Runner.new(catalog, options[:source], drivers).run + end + end +rescue CatalogError => error + warn "Test catalog error: #{error.message}" + exit 1 +end diff --git a/test.sh b/test.sh deleted file mode 100755 index 584af18..0000000 --- a/test.sh +++ /dev/null @@ -1,337 +0,0 @@ -#!/usr/bin/env bash - -fail() { - echo "FAIL: $*" - exit 1 -} - -while [[ $1 = -* ]]; do - case $1 in - -t) - timetest=1 - shift - ;; - -s) - shift - src=$1 - shift - ;; - --) - shift - break - ;; - *) - echo "Unknown option: $1" >&2 - exit 2 - ;; - esac -done - -src=${src:-${1:-bj.sh}} -echo "Testing $src" -. "$src" - -#set -x - -: "-----" - -runteststatus() { - local expected_status=$1 ans=$2 - shift 2 - echo "*** status=$expected_status: $*" - local c r - r=$(bj "$@") - c=$? - echo "c=$c r=$r" - [[ $r = "$ans" ]] || fail "$r != $ans" - (( c == expected_status )) \ - || fail "bj exit code: $c != $expected_status" - echo "pass" -} - -runtest() { - runteststatus 0 "$@" -} - -runstdin() { - echo "*** stdin: $*" - ans=$1 - data=$2 - shift 2 - local e r - for e in '' $'\n'; do - r=$(printf %s "$data$e" | bj - "$@") - c=$? - echo "c=$c r=$r" - [[ $r = "$ans" ]] || fail "$r != $ans" - (( c == 0 )) || fail "bj exit code: $c" - done -} - -runtest bar '{"foo": "bar"}' foo || fail "bj exit code: $?" - -runtest '{"bar": [1, 2, 3]}' '{"a": "b", "foo": {"bar": [1, 2, 3]}}' foo - -runtest 'baz' \ - '{"a": {"b": "c"}, "d": {"e": {"f": "g"}, "h": "i"}, "foo": {"bar": "baz"}}' \ - foo bar - -runtest -2 '{"a": {"b": {"c" : ["d" , "e"]} } , -"foo" : {"bar": [1.0, -2, 3e45] } }' foo bar 1 - -# Whitespace test -runtest g ' { "a" -: { -"b" : { "c" : [ "d" , "e" ] , "e" : [ "f" , "g" ] } } , -"foo" : {"bar": -[1, 2, 3] } } ' a b e 1 - -# All four JSON whitespace characters: space, tab, carriage return, line feed -runtest 1 $'{\r\n\t"a"\t: \r1\n}' a || fail "JSON whitespace test failed" -runtest 42 $'{\r\n\t"outer" : [\r\n\t{"value"\t:\r 42\n}\n]\r}' \ - outer 0 value || fail "Nested JSON whitespace test failed" - -runtest true ' [ false, {"thing": [true, false]}]' 1 thing 0 - -# Stdin tests, with and without a trailing newline -runstdin 123 123 -runstdin true true -runstdin false false -runstdin null null -runstdin string '"string"' -runstdin '[1,2]' '[1,2]' -runstdin '{"a":1}' '{"a":1}' -runstdin node-1 '{"metadata":{"name":"node-1"}}' metadata name - -# The parser must work when inherited shell options include nounset without -# changing the caller's option state. -echo "*** nounset caller: $src" -bash -u /dev/stdin "$src" <<'EOF' \ - || fail "$src failed with nounset enabled" -src=$1 -. "$src" - -check() { - local expected=$1 - shift - local c r - r=$(bj "$@") - c=$? - [[ $r = "$expected" && $c = 0 ]] || exit 1 -} - -json='{"object":{"string":"value","number":42,"true":true,"false":false,"null":null,"array":[0,{"nested":"hit"}]}}' -check '{"string":"value","number":42,"true":true,"false":false,"null":null,"array":[0,{"nested":"hit"}]}' "$json" object -check value "$json" object string -check 42 "$json" object number -check true "$json" object true -check false "$json" object false -check null "$json" object null -check '[0,{"nested":"hit"}]' "$json" object array -check hit "$json" object array 1 nested - -r=$(bj "$json" object missing) -c=$? -[[ -z $r && $c = 1 ]] || exit 1 -[[ $- = *u* ]] || exit 1 -EOF -echo pass - -# Array out of bounds test -runteststatus 1 '' '[0, 1, 2, 3]' 4 - -runteststatus 1 '' '{"a": [0, 1, 2, 3]}' a 4 - -runtest 11 '{"a": [0, 1, 2], "b": [10, 11, 12]}' b 1 \ - || fail "bad exit code after valid array index query: $?" - -# Nested array tests -runtest 4 '{"a": [[0, 42], 1, [2, [3, 4]]]}' a 2 1 1 -runtest i '[{"b": "c", "e": {"f": "g"}}, {"h": "i"}]' 1 h - -# Strings with delimiters in containers -runtest '{"x":"b}c"}' '{"a":{"x":"b}c"}}' a -runtest '["x]y"]' '{"a":["x]y"]}' a -runtest 'c' '["a,b","c"]' 1 -runtest 'c' '["a]b","c"]' 1 - -# Object keys must only match at the current container depth -runtest top '{"outer":{"target":"nested"},"target":"top"}' target -runteststatus 1 '' '{"outer":{"target":"nested"},"other":0}' target -runtest top '{"items":[{"target":"nested"}],"target":"top"}' target -runteststatus 1 '' '{"items":[{"target":"nested"}],"other":0}' target -runtest top '{"target":"top","outer":{"target":"nested"}}' target -runtest direct \ - '{"level":{"child":{"target":"nested"},"target":"direct"}}' level target - -# Traversal must stop when a selected value is a scalar. -runteststatus 1 '' '{"a":"x","b":"y"}' a extra -runteststatus 1 '' '{"b":"y","a":"x"}' a extra -runteststatus 1 '' '{"a":1,"b":2}' a extra -runteststatus 1 '' '{"b":2,"a":1}' a extra -runteststatus 1 '' '{"a":true,"b":false}' a extra -runteststatus 1 '' '{"b":false,"a":true}' a extra -runteststatus 1 '' '{"a":false,"b":true}' a extra -runteststatus 1 '' '{"b":true,"a":false}' a extra -runteststatus 1 '' '{"a":null,"b":0}' a extra -runteststatus 1 '' '{"b":0,"a":null}' a extra -runteststatus 1 '' '[1,2]' 0 extra -runteststatus 1 '' '[1,2]' 1 extra -runteststatus 1 '' '{"outer":{"a":1,"b":2}}' outer a extra -runteststatus 1 '' '{"a":1,"b":2}' a extra more - -# Numbers tests -runtest "4.2e10" '[0, -1, 4.2e10]' 2 -runtest "-1" '[0, -1, 4.2e10]' 1 -runtest "1e+2" '[1e+2]' 0 - -# Array iteration test -#set -x -echo '*** {"a": [42, 69, 420]} a $i (iterate)' -j='{"a": [42, 69, 420]}' -i=0 -s=() -while :; do - r=$(bj "$j" a "$i") - c=$? - if (( c != 0 )); then - terminal_status=$c - break - fi - s+=("$r") - ((i++)) -done -echo "c=$terminal_status count=${#s[@]} values=${s[*]}" -(( terminal_status == 1 )) \ - || fail "array iteration exit code: $terminal_status != 1" -(( ${#s[@]} == 3 )) || fail "array iteration count: ${#s[@]} != 3" -[[ ${s[0]} = 42 && ${s[1]} = 69 && ${s[2]} = 420 ]] \ - || fail "array iteration values: ${s[*]} != 42 69 420" -echo pass - -# Closing brackets in strings test -runtest 'baz' '{"foo": {"b}ar": "baz"}}' foo 'b}ar' \ - || fail "Wrongly detected closing bracket inside string" -runtest 'b}az' '{"foo": {"bar": "b}az"}}' foo 'bar' \ - || fail "Wrongly detected closing bracket inside string" - -# Escape spelling preservation tests -runtest 'a\"b' '{"value":"a\"b"}' value \ - || fail "Escaped quote was not preserved" -runtest 'a\\b' '{"value":"a\\b"}' value \ - || fail "Escaped backslash was not preserved" -runtest 'a\/b' '{"value":"a\/b"}' value \ - || fail "Escaped solidus was not preserved" -runtest 'a\bb' '{"value":"a\bb"}' value \ - || fail "Escaped backspace was not preserved" -runtest 'a\fb' '{"value":"a\fb"}' value \ - || fail "Escaped form feed was not preserved" -runtest 'a\nb' '{"value":"a\nb"}' value \ - || fail "Escaped newline was not preserved" -runtest 'a\rb' '{"value":"a\rb"}' value \ - || fail "Escaped carriage return was not preserved" -runtest 'a\tb' '{"value":"a\tb"}' value \ - || fail "Escaped tab was not preserved" -runstdin 'line1\nline2' '{"value":"line1\nline2"}' value - -# Escaped structural characters must remain inside the string. -runtest 'x\"}y,]z:{' '{"outer":{"value":"x\"}y,]z:{","after":1}}' \ - outer value || fail "Escaped structural text changed parser state" - -# Raw UTF-8 and escaped Unicode spellings are intentionally distinct. -runtest 'café 雪 🚀' '{"raw":"café 雪 🚀"}' raw \ - || fail "Raw UTF-8 value did not round-trip" -runtest value '{"café 雪":"value"}' 'café 雪' \ - || fail "Raw UTF-8 key was not queryable" -runtest 'caf\u00e9 \u96ea' '{"escaped":"caf\u00e9 \u96ea"}' escaped \ - || fail "Escaped BMP spelling was not preserved" -runtest '\uD83D\uDE80' '{"emoji":"\uD83D\uDE80"}' emoji \ - || fail "Surrogate-pair spelling was not preserved" -runtest 42 '{"\u0061":42}' '\u0061' \ - || fail "Escaped object key spelling was not queryable" -runteststatus 1 '' '{"\u0061":42}' a -runtest '\u0000' '{"nul":"\u0000"}' nul \ - || fail "NUL escape spelling was not preserved" - -# Skipped strings must still recognize a complete escape pair. -runtest hit '{"skip":"\\","target":"hit"}' target \ - || fail "Escape in skipped string changed parser state" - -# Selected containers must preserve escape spelling for subsequent queries. -runtest '{"slash":"\\","value":"line1\nline2"}' \ - '{"outer":{"slash":"\\","value":"line1\nline2"}}' outer \ - || fail "Container escape spelling was not preserved" - -# Empty values, nulls, and navigation around empty containers -empty_values='{"empty_string":"","empty_object":{},"empty_array":[],"nothing":null}' -runtest '' "$empty_values" empty_string -runtest '{}' "$empty_values" empty_object -runtest '[]' "$empty_values" empty_array -runtest null "$empty_values" nothing -runteststatus 1 '' "$empty_values" absent - -empty_object=$(bj "$empty_values" empty_object) -empty_array=$(bj "$empty_values" empty_array) -runtest '{}' "$empty_object" -runtest '[]' "$empty_array" - -runtest hit '{"a":{},"b":[],"c":{"result":"hit"}}' c result -runtest hit \ - '[0,1,2,3,4,5,6,7,8,9,{"result":"hit"}]' 10 result -runtest hit '[{},[],{"result":"hit"}]' 2 result - -# Empty caller query terms are unsupported and fail when reached. -runteststatus 2 '' '{"":42}' '' -runteststatus 2 '' '{"outer":{"":"value"}}' outer '' -runteststatus 2 '' '{"outer":{"leaf":1}}' '' outer -runteststatus 2 '' '{"outer":{"leaf":1}}' outer '' leaf -runteststatus 2 '' '{"outer":{"leaf":1}}' outer leaf '' -runteststatus 1 '' '{"outer":{"leaf":1}}' missing '' -runtest 7 '{"":42,"normal":7}' normal -runtest '{"":42}' '{"":42}' -runtest '' '{"value":""}' value - -# Representative Kubernetes-shaped data used by build and shell automation -kubernetes_json='{"items":[{"metadata":{"name":"api","annotations":{"example.com/config":"line1\nline2"}},"spec":{"nodeName":null,"containers":[{"name":"app","env":[{"name":"MODE","value":""}]}]}}]}' -runtest api "$kubernetes_json" items 0 metadata name -runtest 'line1\nline2' \ - "$kubernetes_json" items 0 metadata annotations example.com/config -runtest app "$kubernetes_json" items 0 spec containers 0 name -runtest '' "$kubernetes_json" items 0 spec containers 0 env 0 value -runtest null "$kubernetes_json" items 0 spec nodeName -runteststatus 1 '' "$kubernetes_json" absent - -# Representative cloud-init variables used by provisioning scripts -cloud_init_json='{"hostname":"node-01","enabled":true,"proxy":null,"ssh_authorized_keys":[],"write_files":[{"path":"/etc/app.conf","content":"mode=prod\nurl=https:\/\/example.test\/api"}]}' -runtest node-01 "$cloud_init_json" hostname -runtest true "$cloud_init_json" enabled -runtest null "$cloud_init_json" proxy -runtest '[]' "$cloud_init_json" ssh_authorized_keys -runtest /etc/app.conf "$cloud_init_json" write_files 0 path -runtest 'mode=prod\nurl=https:\/\/example.test\/api' \ - "$cloud_init_json" write_files 0 content -runteststatus 1 '' "$cloud_init_json" absent - -if (( timetest )); then - set +x - echo '*** time r=$(bj "$(< citylots.json)" features 1000 geometry coordinates 0 0 1)' - time r=$(bj "$(< citylots.json)" features 1000 geometry coordinates 0 0 1) - echo "r=$r" - if [[ $r = 37.805335380794915 ]]; then - echo pass - else - echo "FAIL: $r != 37.805335380794915" - fi - - echo '*** time r=$(bj - features 1000 geometry coordinates 0 0 1 Date: Tue, 1 Sep 2026 05:13:57 -0400 Subject: [PATCH 2/2] Harden YAML test harness validation --- test.rb | 37 ++++++++++++++++++---- verify.sh | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/test.rb b/test.rb index 51ee47b..91c3ccb 100755 --- a/test.rb +++ b/test.rb @@ -7,25 +7,33 @@ NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ QUERY_DRIVER = <<~'BASH' + [[ $- != *u* ]] || trap '[[ $- == *u* ]] || exit 125' EXIT . "$1" || exit 126 shift - [[ $- != *u* ]] || trap '[[ $- == *u* ]] || exit 125' EXIT bj "$@" BASH # A large fixture cannot cross exec's argument-size limit, so Bash must turn # this one file into a function argument after the process starts. FILE_ARGUMENT_DRIVER = <<~'BASH' + [[ $- != *u* ]] || trap '[[ $- == *u* ]] || exit 125' EXIT . "$1" || exit 126 json_file=$2 shift 2 - [[ $- != *u* ]] || trap '[[ $- == *u* ]] || exit 125' EXIT bj "$(<"$json_file")" "$@" BASH class CatalogError < StandardError; end class Catalog + DOCUMENT_KEYS = %w[version report fixtures tests].freeze + REPORT_KEYS = %w[success failure].freeze + TEST_KEYS = %w[name description input query expected shell tags operation].freeze + INPUT_KEYS = %w[json fixture file transport trailing-newline].freeze + EXPECTED_KEYS = %w[output status].freeze + SHELL_KEYS = %w[nounset].freeze + SUPPORTED_TAGS = %w[timing].freeze + attr_reader :report, :tests def initialize(path, include_timing: false) @@ -63,10 +71,13 @@ def input_for(test) def validate_document(document) raise CatalogError, 'catalog must contain a YAML mapping' unless document.is_a?(Hash) + reject_unknown_keys('catalog', document, DOCUMENT_KEYS) raise CatalogError, 'unsupported catalog version' unless document['version'] == 1 + report = document['report'] + raise CatalogError, 'report must be a mapping' unless report.is_a?(Hash) + reject_unknown_keys('report', report, REPORT_KEYS) raise CatalogError, 'report must contain success and failure strings' unless - document['report'].is_a?(Hash) && - %w[success failure].all? { |key| document['report'][key].is_a?(String) } + REPORT_KEYS.all? { |key| report[key].is_a?(String) } raise CatalogError, 'fixtures must be a mapping' unless document.fetch('fixtures', {}).is_a?(Hash) raise CatalogError, 'fixture values must be strings' unless @@ -78,6 +89,7 @@ def validate_tests(tests) names = {} tests.each_with_index do |test, index| raise CatalogError, "test #{index + 1} must be a mapping" unless test.is_a?(Hash) + reject_unknown_keys("test #{index + 1}", test, TEST_KEYS) name = test['name'] raise CatalogError, "test #{index + 1} has an invalid name" unless name.is_a?(String) && NAME_PATTERN.match?(name) @@ -91,14 +103,18 @@ def validate_tests(tests) validate_input(name, test['input']) validate_expected(name, test) validate_shell(name, test.fetch('shell', {})) + tags = test.fetch('tags', []) raise CatalogError, "#{name}: tags must be an array of strings" unless - test.fetch('tags', []).is_a?(Array) && - test.fetch('tags', []).all? { |tag| tag.is_a?(String) } + tags.is_a?(Array) && tags.all? { |tag| tag.is_a?(String) } + unsupported = tags - SUPPORTED_TAGS + raise CatalogError, "#{name}: unsupported tags #{unsupported.map(&:inspect).join(', ')}" \ + unless unsupported.empty? end end def validate_input(name, input) raise CatalogError, "#{name}: input must be a mapping" unless input.is_a?(Hash) + reject_unknown_keys("#{name} input", input, INPUT_KEYS) sources = %w[json fixture file].select { |key| input.key?(key) } raise CatalogError, "#{name}: input needs exactly one source" unless sources.length == 1 source = sources.first @@ -121,6 +137,7 @@ def validate_input(name, input) def validate_expected(name, test) expected = test['expected'] raise CatalogError, "#{name}: expected must be a mapping" unless expected.is_a?(Hash) + reject_unknown_keys("#{name} expected", expected, EXPECTED_KEYS) raise CatalogError, "#{name}: expected status must be an integer" unless expected['status'].is_a?(Integer) operation = test.fetch('operation', 'query') @@ -135,10 +152,18 @@ def validate_expected(name, test) def validate_shell(name, shell) raise CatalogError, "#{name}: shell must be a mapping" unless shell.is_a?(Hash) + reject_unknown_keys("#{name} shell", shell, SHELL_KEYS) return unless shell.key?('nounset') && ![true, false].include?(shell['nounset']) raise CatalogError, "#{name}: shell nounset must be true or false" end + def reject_unknown_keys(context, mapping, allowed) + unknown = mapping.keys - allowed + return if unknown.empty? + label = unknown.length == 1 ? 'key' : 'keys' + raise CatalogError, "#{context}: unknown #{label} #{unknown.map(&:inspect).join(', ')}" + end + def timing?(test) test.fetch('tags', []).include?('timing') end diff --git a/verify.sh b/verify.sh index e0f66dd..710428d 100755 --- a/verify.sh +++ b/verify.sh @@ -36,10 +36,100 @@ status=$? [[ -z $output && $status = 1 ]] \ || fail "bj.sh CLI missing-query status failed" -echo "Checking generated files" verify_dir=$(mktemp -d) || fail "Could not create temporary directory" trap 'rm -r -- "$verify_dir"' EXIT +echo "Checking test harness guardrails" +bad_source="$verify_dir/disables-nounset.sh" +bad_source_log="$verify_dir/disables-nounset.log" +printf '. %q\nset +u\n' "$PWD/bj.sh" > "$bad_source" +if ./test.rb -s "$bad_source" > "$bad_source_log" 2>&1; then + fail "test runner accepted a source that disables nounset" +fi +grep -F 'PASS top-level-string-value' "$bad_source_log" > /dev/null \ + || fail "nounset guardrail source failed unrelated tests" +grep -F 'FAIL nounset-object-output' "$bad_source_log" > /dev/null \ + || fail "test runner did not reject source-time nounset corruption" +grep -F 'status actual=125 expected=0' "$bad_source_log" > /dev/null \ + || fail "nounset corruption did not use the runner guardrail status" + +file_argument_json="$verify_dir/file-argument.json" +file_argument_catalog="$verify_dir/file-argument.yml" +file_argument_log="$verify_dir/file-argument.log" +printf %s '{"value":"ok"}' > "$file_argument_json" +printf '%s\n' \ + 'version: 1' \ + 'report:' \ + ' success: "PASS %{name}: %{description}"' \ + ' failure: "FAIL %{name}: %{description}"' \ + 'tests:' \ + ' - name: nounset-file-argument' \ + ' description: rejects source-time nounset corruption for file arguments' \ + ' input:' \ + ' file: file-argument.json' \ + ' transport: argument' \ + ' query: [value]' \ + ' shell: {nounset: true}' \ + ' expected:' \ + " output: 'ok'" \ + ' status: 0' \ + > "$file_argument_catalog" +if ./test.rb -f "$file_argument_catalog" -s "$bad_source" \ + > "$file_argument_log" 2>&1 +then + fail "file-argument driver accepted a source that disables nounset" +fi +grep -F 'FAIL nounset-file-argument' "$file_argument_log" > /dev/null \ + || fail "file-argument driver did not reject source-time nounset corruption" +grep -F 'status actual=125 expected=0' "$file_argument_log" > /dev/null \ + || fail "file-argument nounset corruption did not use guardrail status" + +check_catalog_rejected() { + local name=$1 from=$2 to=$3 expected=$4 + local catalog="$verify_dir/$name.yml" + local log="$verify_dir/$name.log" + + ruby -e ' + input, output, from, to = ARGV + text = File.read(input) + abort "Source text not found: #{from.inspect}" unless text.sub!(from, to) + File.write(output, text) + ' tests.yml "$catalog" "$from" "$to" \ + || fail "could not prepare $name catalog check" + if ./test.rb -f "$catalog" > "$log" 2>&1; then + fail "test runner accepted $name" + fi + grep -F "$expected" "$log" > /dev/null \ + || fail "test runner rejected $name for the wrong reason" +} + +check_catalog_rejected unknown-document-key \ + 'version: 1' $'version: 1\nunknown: true' \ + 'catalog: unknown key "unknown"' +check_catalog_rejected unknown-report-key \ + $'report:\n success:' $'report:\n unknown: true\n success:' \ + 'report: unknown key "unknown"' +check_catalog_rejected unknown-test-key \ + ' - name: top-level-string-value' \ + $' - name: top-level-string-value\n unknown: true' \ + 'test 1: unknown key "unknown"' +check_catalog_rejected unknown-input-key \ + $' input:\n json:' \ + $' input:\n transprot: stdin\n json:' \ + 'top-level-string-value input: unknown key "transprot"' +check_catalog_rejected unknown-expected-key \ + $' expected:\n output:' \ + $' expected:\n statsu: 0\n output:' \ + 'top-level-string-value expected: unknown key "statsu"' +check_catalog_rejected unknown-shell-key \ + ' shell: {nounset: true}' \ + ' shell: {nounset: true, nounsett: true}' \ + 'nounset-object-output shell: unknown key "nounsett"' +check_catalog_rejected unsupported-tag \ + ' tags: [timing]' ' tags: [benchmark]' \ + 'timing-large-fixture-argument: unsupported tags "benchmark"' + +echo "Checking generated files" ./rollup.rb bj.sh "$verify_dir/bj-1line.sh" \ || fail "Could not regenerate bj-1line.sh" ./linebreak.rb --max-lines 13 80 \