diff --git a/examples/barc-fable-game/BARC_LIVE_RECEIVER_PINS.schema.json b/examples/barc-fable-game/BARC_LIVE_RECEIVER_PINS.schema.json index 59982d6..e8bbbf6 100644 --- a/examples/barc-fable-game/BARC_LIVE_RECEIVER_PINS.schema.json +++ b/examples/barc-fable-game/BARC_LIVE_RECEIVER_PINS.schema.json @@ -5,7 +5,8 @@ "oneOf": [ { "$ref": "#/$defs/liveV1" }, { "$ref": "#/$defs/tacticalV2" }, - { "$ref": "#/$defs/stockTacticalV3" } + { "$ref": "#/$defs/stockTacticalV3" }, + { "$ref": "#/$defs/sharedStockTacticalV4" } ], "$defs": { "source": { @@ -64,6 +65,54 @@ "source": { "$ref": "#/$defs/source" }, "files": { "type": "array", "minItems": 11, "items": { "$ref": "#/$defs/file" } } } + }, + "sharedStockTacticalV4": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "profile", + "protocolVersion", + "tacticalRevision", + "queueEvidenceScheme", + "guestAbiVersion", + "source", + "files", + "runtime" + ], + "properties": { + "schema": { + "const": "fsbar.barc-live-receiver-pins/v4" + }, + "profile": { + "const": "barc-live-tactical-stock-v1" + }, + "protocolVersion": { + "const": 2 + }, + "tacticalRevision": { + "const": 2 + }, + "queueEvidenceScheme": { + "const": 2 + }, + "guestAbiVersion": { + "const": 1 + }, + "source": { + "$ref": "#/$defs/source" + }, + "files": { + "type": "array", + "minItems": 11, + "items": { + "$ref": "#/$defs/file" + } + }, + "runtime": { + "const": "shared-wasm-0.2.0" + } + } } } } diff --git a/scripts/adopt-barc-preview.sh b/scripts/adopt-barc-preview.sh index 5d4b094..1242fd8 100755 --- a/scripts/adopt-barc-preview.sh +++ b/scripts/adopt-barc-preview.sh @@ -52,15 +52,23 @@ required=( src/Broker.Browser.Client/dist/assets/barc-preview.css src/Broker.Browser.Wasm/guest-worker.js ) +# Legacy archives keep their existing layout; shared archives prove the whole graph. +if [[ -d "$stage/src/Broker.Browser.SharedWasm" ]]; then + python3 "$repo_root/scripts/check-barc-shared-closure.py" "$stage" --manifest + required[2]=src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/module-worker.mjs +fi if [[ -f "$stage/src/BARC-LIVE-PINS.json" ]]; then jq -e ' - (.schema == "fsbar.barc-live-receiver-pins/v1" or .schema == "fsbar.barc-live-receiver-pins/v2") and + (.schema == "fsbar.barc-live-receiver-pins/v1" or .schema == "fsbar.barc-live-receiver-pins/v2" or .schema == "fsbar.barc-live-receiver-pins/v3" or .schema == "fsbar.barc-live-receiver-pins/v4") and ([.files[] | select(.role == "manualGuest")] | length == 1) and ([.files[] | select(.role == "customGuest")] | length == 1) ' "$stage/src/BARC-LIVE-PINS.json" >/dev/null || { echo "archive contains invalid live receiver pins" >&2 exit 2 } + if [[ "$(jq -r '.schema' "$stage/src/BARC-LIVE-PINS.json")" == fsbar.barc-live-receiver-pins/v4 ]]; then + python3 "$repo_root/scripts/check-barc-shared-closure.py" "$stage" --pins "$stage/src/BARC-LIVE-PINS.json" --manifest + fi mapfile -t live_guests < <( jq -r '.files[] | select(.role == "manualGuest" or .role == "customGuest") | .archivePath' \ "$stage/src/BARC-LIVE-PINS.json" diff --git a/scripts/check-barc-shared-closure.py b/scripts/check-barc-shared-closure.py new file mode 100644 index 0000000..8d68e93 --- /dev/null +++ b/scripts/check-barc-shared-closure.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +"""Check deployed BAR shared assets and their existing pins/manifest; launch nothing.""" +import argparse +import hashlib +import json +import os +import pathlib +import re + +SHARED = 'src/Broker.Browser.SharedWasm/' +WASM = 'src/Broker.Browser.Wasm/' +ENTRIES = [SHARED + 'index.js', SHARED + 'fable/Adapter.js', + SHARED + '_content/FS.GG.Wasm.Browser/worker-client.mjs', + SHARED + '_content/FS.GG.Wasm.Browser/module-worker.mjs', + SHARED + '_content/FS.GG.Wasm.Browser/policy/Host.js', + SHARED + '_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js', + WASM + 'index.js', WASM + 'barc-wire.js', WASM + 'wasm-profile.js', + WASM + 'guest-worker.js', WASM + 'guest-supervisor.js'] +DIGEST = re.compile(r'[0-9a-f]{64}') + +def module_tokens(text): + """Lex the generated module subset without executing JavaScript. + + Comments and strings cannot conceal imports; template substitutions are + scanned as code. Ambiguous slash goals and escaped identifiers refuse. + This is a source guard, not JavaScript syntax/semantic validation. + """ + tokens = [] + length = len(text) + line_terminators = '\r\n\u2028\u2029' + expression_prefixes = {'(', '[', '=', ',', ':', ';', '!', '?', '+', '-', '*', + '%', '&', '|', '^', '~', '<', '>', 'return', 'throw', + 'case', 'delete', 'void', 'typeof', 'yield', 'await', '=>', + 'else', 'do', 'break', 'continue', 'debugger', 'default', + 'extends', + '/', 'in', 'instanceof', '...', '**', '&&', '||', '??', + '<<', '>>', '>>>', '<=', '>=', '==', '!=', '===', '!==', + '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '**=', + '&&=', '||=', '??='} + operators = sorted(expression_prefixes | {'++', '--', '?.'}, key=len, reverse=True) + + def scan(index, template_expression=False): + braces = 0 + parens = [] + previous = None + regex_allowed = True + while index < length: + char = text[index] + if char.isspace(): + index += 1 + continue + if text.startswith('//', index): + index += 2 + while index < length and text[index] not in line_terminators: + index += 1 + continue + if text.startswith('/*', index): + close = text.find('*/', index + 2) + if close < 0: + raise ValueError('unterminated module comment') + index = close + 2 + continue + start = index + if char in ('"', "'"): + quote = char + index += 1 + while index < length: + if text[index] == '\\': + index += 2 + elif text[index] == quote: + index += 1 + break + elif text[index] in '\r\n': + raise ValueError('unsupported multiline module string') + else: + index += 1 + else: + raise ValueError('unterminated module string') + tokens.append(('string', text[start + 1:index - 1], start, index)) + previous, regex_allowed = 'literal', False + continue + if char == '`': + tokens.append(('template', '`', index, index + 1)) + index += 1 + while index < length: + if text[index] == '\\': + index += 2 + elif text[index] == '`': + index += 1 + break + elif text.startswith('${', index): + index = scan(index + 2, True) + else: + index += 1 + else: + raise ValueError('unterminated module template') + previous, regex_allowed = 'literal', False + continue + if char == '/' and previous in ('}', 'of'): + # `of` is contextual and may also be an ordinary identifier. + # Refuse this slash rather than guessing a for-of lexical goal. + raise ValueError('unsupported ambiguous module slash') + if char == '/' and not regex_allowed and tokens and any( + value in line_terminators for value in text[tokens[-1][3]:index]): + # A labeled break/continue can end by ASI before a regexp. Do + # not guess that a cross-line slash must be division. + raise ValueError('unsupported ambiguous multiline module slash') + if char == '/' and regex_allowed: + index += 1 + in_class = False + while index < length: + value = text[index] + if value == '\\': + index += 2 + continue + if value in line_terminators: + raise ValueError('unterminated module regexp') + if value == '[': + in_class = True + elif value == ']': + in_class = False + elif value == '/' and not in_class: + index += 1 + while index < length and text[index].isalpha(): + index += 1 + break + index += 1 + else: + raise ValueError('unterminated module regexp') + tokens.append(('regexp', '', start, index)) + previous, regex_allowed = 'literal', False + continue + if char == '\\': + raise ValueError('unsupported escaped module identifier') + if char.isalpha() or char in '_$': + index += 1 + while index < length and (text[index].isalnum() or text[index] in '_$'): + index += 1 + value = text[start:index] + elif char.isdigit(): + index += 1 + while index < length and (text[index].isalnum() or text[index] in '._'): + index += 1 + value = 'number' + else: + value = next((operator for operator in operators + if not operator[0].isalpha() and text.startswith(operator, index)), char) + index += len(value) + tokens.append(('token', value, start, index)) + if value == '(': + parens.append(previous in {'if', 'while', 'for', 'with', 'switch', 'catch'}) + regex_allowed = True + elif value == ')': + regex_allowed = parens.pop() if parens else False + elif value == '{': + braces += 1 + regex_allowed = True + elif value == '}': + if template_expression and braces == 0: + return index + braces -= 1 + regex_allowed = False + elif value in ('++', '--'): + # Prefix operators still expect an operand; postfix operators + # preserve the division goal of the preceding expression. + pass + else: + regex_allowed = value in expression_prefixes and previous not in ('.', '?.') + previous = 'property' if previous in ('.', '?.') else value + if template_expression: + raise ValueError('unterminated module template substitution') + return index + + scan(0) + return tokens + + +def runtime_imports(text): + tokens = module_tokens(text) + imports = [] + + def literal(position): + if position >= len(tokens) or tokens[position][0] != 'string': + raise ValueError('nonliteral shared runtime import') + value = tokens[position][1] + if '\\' in value: + raise ValueError('escaped shared runtime import') + return value + + def declaration_end(position): + if position == len(tokens) or tokens[position][1] == ';': + return + if any(char in '\r\n\u2028\u2029' for char in text[tokens[position - 1][3]:tokens[position][2]]): + return + raise ValueError('unsupported shared import declaration suffix') + + for index, token in enumerate(tokens): + if token[0] != 'token' or token[1] not in ('import', 'export'): + continue + if index and tokens[index - 1][1] in ('.', '?.'): + continue + next_index = index + 1 + if next_index >= len(tokens): + raise ValueError('incomplete shared import declaration') + next_value = tokens[next_index][1] + if token[1] == 'import' and next_value == '.': + if next_index + 1 >= len(tokens) or tokens[next_index + 1][1] != 'meta': + raise ValueError('unsupported shared import meta expression') + continue + if token[1] == 'import' and next_value == '(': + target = literal(next_index + 1) + if next_index + 2 >= len(tokens) or tokens[next_index + 2][1] != ')': + raise ValueError('nonliteral shared runtime import') + imports.append(target) + continue + if token[1] == 'import' and tokens[next_index][0] == 'string': + imports.append(literal(next_index)) + declaration_end(next_index + 1) + continue + if token[1] == 'export' and next_value not in ('*', '{'): + continue + depth = 0 + position = next_index + while position < len(tokens): + value = tokens[position][1] + if value == '{': + depth += 1 + elif value == '}': + depth -= 1 + elif depth == 0 and value == 'from': + imports.append(literal(position + 1)) + declaration_end(position + 2) + break + elif depth == 0 and value == ';': + if token[1] == 'import': + raise ValueError('unsupported shared import declaration') + break + position += 1 + else: + if token[1] == 'import': + raise ValueError('incomplete shared import declaration') + return imports + +def regular(root, relative): + path = pathlib.PurePosixPath(relative) + if path.is_absolute() or path.as_posix() != relative or not path.parts or any(part in ('', '.', '..') for part in path.parts): + raise ValueError('unsafe asset path: ' + relative) + physical = root + for part in path.parts: + physical /= part + if physical.is_symlink(): + raise ValueError('symlink in asset path: ' + relative) + if not physical.is_file() or not physical.resolve().is_relative_to(root): + raise ValueError('missing or escaping asset: ' + relative) + if physical.stat().st_size > 8 * 1024 * 1024: + raise ValueError('asset exceeds source inspection bound: ' + relative) + return physical + +def bounded_files(directory): + # Bound traversal before population growth, including empty/deep directories. + stack = [(directory, 0)] + files = directories = 0 + while stack: + current, depth = stack.pop() + if depth > 32: + raise ValueError('deployed directory depth bound exceeded') + with os.scandir(current) as entries: + for entry in entries: + if entry.is_symlink(): + raise ValueError('symbolic deployed entry: ' + entry.name) + if entry.is_dir(follow_symlinks=False): + directories += 1 + if directories > 256 or depth == 32: + raise ValueError('deployed directory population/depth bound exceeded') + stack.append((pathlib.Path(entry.path), depth + 1)) + elif entry.is_file(follow_symlinks=False): + files += 1 + if files > 512: + raise ValueError('deployed file population bound exceeded') + yield pathlib.Path(entry.path) + else: + raise ValueError('nonregular deployed entry: ' + entry.name) + +def digest(root, relative): + return hashlib.sha256(regular(root, relative).read_bytes()).hexdigest() + +def check(root, pins=None, manifest=False): + root = root.resolve(strict=True) + assets = set(ENTRIES) + for subtree in ('fable', '_content'): + directory = root / SHARED / subtree + if not directory.is_dir() or directory.is_symlink(): + raise ValueError('missing or symbolic deployed directory: ' + subtree) + for path in bounded_files(directory): + assets.add(path.relative_to(root).as_posix()) + if len(assets) > 512: + raise ValueError('shared asset inspection bound exceeded') + if len(assets) > 512: + raise ValueError('shared asset inspection bound exceeded') + inspected_bytes = 0 + scripts = {} + for relative in sorted(assets): + path = regular(root, relative) + inspected_bytes += path.stat().st_size + if inspected_bytes > 32 * 1024 * 1024: + raise ValueError('shared aggregate inspection byte bound exceeded') + if path.suffix in ('.dll', '.exe', '.so', '.dylib'): + raise ValueError('assembly in deployed shared graph: ' + relative) + if path.suffix not in ('.js', '.mjs'): + continue + scripts[relative] = path.read_text(encoding='utf-8') + # Fable copies its complete library, including unused Node-only modules. + # Inventory and hash every file, but qualify runtime imports from the + # explicit host/Worker roots rather than treating every copied file as loaded. + pending = list(ENTRIES) + visited = set() + while pending: + relative = pending.pop() + if relative in visited: + continue + visited.add(relative) + text = scripts.get(relative) + if text is None: + continue + path = regular(root, relative) + for specifier in runtime_imports(text): + if not specifier.startswith('./') and not specifier.startswith('../'): + raise ValueError('nonrelative shared runtime import: ' + specifier) + target = (path.parent / specifier).resolve() + if not target.is_relative_to(root): + raise ValueError('escaping shared runtime import: ' + specifier) + target_relative = target.relative_to(root).as_posix() + if target_relative not in assets: + raise ValueError('unlisted shared runtime import: ' + target_relative) + regular(root, target_relative) + pending.append(target_relative) + if pins is not None: + if pins.get('schema') != 'fsbar.barc-live-receiver-pins/v4' or pins.get('runtime') != 'shared-wasm-0.2.0': + raise ValueError('shared runtime requires explicit v4 pins') + entries = {entry['archivePath']: entry for entry in pins['files']} + if len(entries) != len(pins['files']): + raise ValueError('duplicate pinned archive path') + selected = {path for path in entries if path.startswith(SHARED)} + if selected != {path for path in assets if path.startswith(SHARED)}: + raise ValueError('pinned shared runtime inventory differs from deployed files') + for relative in assets: + entry = entries.get(relative) + if entry is None or entry['sourcePath'] != relative or not DIGEST.fullmatch(entry['sha256']): + raise ValueError('missing exact source/archive pin: ' + relative) + expected_role = 'worker' if relative == ENTRIES[3] else 'dependency' + if entry['role'] != expected_role or digest(root, relative) != entry['sha256']: + raise ValueError('shared asset role or hash mismatch: ' + relative) + if manifest: + expected = {} + for line in regular(root, 'BARC-PREVIEW.SHA256').read_text().splitlines(): + sha, relative = line.split(maxsplit=1) + if relative in expected or not DIGEST.fullmatch(sha): + raise ValueError('invalid or duplicate manifest entry') + expected[relative] = sha + actual = set() + archive_bytes = 0 + for path in bounded_files(root): + relative = path.relative_to(root).as_posix() + archive_bytes += regular(root, relative).stat().st_size + if archive_bytes > 32 * 1024 * 1024: + raise ValueError('receiver aggregate inspection byte bound exceeded') + actual.add(relative) + actual.discard('BARC-PREVIEW.SHA256') + if set(expected) != actual or not assets.issubset(expected): + raise ValueError('receiver manifest has missing or unlisted files') + for relative, sha in expected.items(): + if digest(root, relative) != sha: + raise ValueError('receiver manifest hash mismatch: ' + relative) + return sorted(assets) + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('root', type=pathlib.Path) + parser.add_argument('--pins', type=pathlib.Path) + parser.add_argument('--manifest', action='store_true') + args = parser.parse_args() + try: + check(args.root, json.loads(args.pins.read_text()) if args.pins else None, args.manifest) + except (ValueError, OSError, KeyError, UnicodeError) as error: + parser.exit(2, 'invalid BAR shared closure: ' + str(error) + '\n') diff --git a/scripts/package-barc-live-receiver.sh b/scripts/package-barc-live-receiver.sh index 4fff23f..46ba734 100755 --- a/scripts/package-barc-live-receiver.sh +++ b/scripts/package-barc-live-receiver.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" usage() { echo "usage: $0 --source --pins --output " >&2 @@ -36,7 +37,10 @@ jq -e ' (has("queueEvidenceScheme") | not)) or (.schema == "fsbar.barc-live-receiver-pins/v3" and .profile == "barc-live-tactical-stock-v1" and .protocolVersion == 2 and .tacticalRevision == 2 and .guestAbiVersion == 1 and - .queueEvidenceScheme == 2)) and + .queueEvidenceScheme == 2) or + (.schema == "fsbar.barc-live-receiver-pins/v4" and .profile == "barc-live-tactical-stock-v1" and + .protocolVersion == 2 and .tacticalRevision == 2 and .guestAbiVersion == 1 and + .queueEvidenceScheme == 2 and .runtime == "shared-wasm-0.2.0")) and (.source.commit | test("^[0-9a-f]{40}$")) and (.files | type == "array" and length >= 7) and ([.files[] | select(.role != "dependency") | .role] | sort == ["clientCss","clientJs","codec","contract","customGuest","manualGuest","worker"]) and @@ -52,15 +56,26 @@ jq -e ' (.archivePath | startswith("src/") or startswith("guests/"))) and (.files[] | select(.role == "clientJs") | .archivePath) == "src/Broker.Browser.Client/dist/assets/barc-preview.js" and (.files[] | select(.role == "clientCss") | .archivePath) == "src/Broker.Browser.Client/dist/assets/barc-preview.css" and - (.files[] | select(.role == "worker") | .archivePath) == "src/Broker.Browser.Wasm/guest-worker.js" and + (.files[] | select(.role == "worker") | .archivePath) == + (if .schema == "fsbar.barc-live-receiver-pins/v4" then + "src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/module-worker.mjs" + else "src/Broker.Browser.Wasm/guest-worker.js" end) and (.files[] | select(.role == "codec") | .archivePath) == "src/Broker.Browser.Contracts/generated/codec.js" and (.files[] | select(.role == "contract") | .archivePath) == "src/Broker.Browser.Contracts/generated/barc_browser.js" and - (. as $pins | all([ + (. as $pins | all((if .schema == "fsbar.barc-live-receiver-pins/v4" then [ + "src/Broker.Browser.Wasm/barc-wire.js", + "src/Broker.Browser.Wasm/index.js", + "src/Broker.Browser.SharedWasm/index.js", + "src/Broker.Browser.SharedWasm/fable/Adapter.js", + "src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/worker-client.mjs", + "src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/policy/Host.js", + "src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js" + ] else [ "src/Broker.Browser.Wasm/barc-wire.js", "src/Broker.Browser.Wasm/wasm-profile.js", "src/Broker.Browser.Wasm/guest-supervisor.js", "src/Broker.Browser.Wasm/index.js" - ][]; . as $path | any($pins.files[]; + ] end)[]; . as $path | any($pins.files[]; .role == "dependency" and .sourcePath == $path and .archivePath == $path))) and all(.files[] | select(.role == "manualGuest" or .role == "customGuest"); .archivePath | test("^guests/[^/]+\\.wasm$")) ' "$pins" >/dev/null || { echo "invalid or incomplete live receiver pins" >&2; exit 2; } @@ -93,6 +108,10 @@ while IFS=$'\t' read -r role source_path archive_path expected_sha; do fi done < <(jq -r '.files[] | [.role,.sourcePath,.archivePath,.sha256] | @tsv' "$pins") +if [[ "$(jq -r '.schema' "$pins")" == fsbar.barc-live-receiver-pins/v4 ]]; then + python3 "$repo_root/scripts/check-barc-shared-closure.py" "$source_root" --pins "$pins" +fi + stage="$(mktemp -d "${TMPDIR:-/tmp}/barc-live-receiver-package.XXXXXX")" cleanup() { rm -rf -- "$stage"; } trap cleanup EXIT @@ -118,11 +137,13 @@ done < <(jq -r '.files[] | [.archivePath,.sha256] | @tsv' "$pins") ( cd "$payload" - find src/Broker.Browser.Client src/Broker.Browser.Contracts/generated src/Broker.Browser.Wasm \ - src/BARC-LIVE-PINS.json guests -type f -print0 \ + find src guests -type f -print0 \ | LC_ALL=C sort -z | xargs -0 sha256sum > BARC-PREVIEW.SHA256 ) +if [[ "$(jq -r '.schema' "$pins")" == fsbar.barc-live-receiver-pins/v4 ]]; then + python3 "$repo_root/scripts/check-barc-shared-closure.py" "$payload" --pins "$pins" --manifest +fi mkdir -p "$(dirname "$output")" tar -C "$payload" --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner \ -czf "$output" BARC-PREVIEW.SHA256 guests src diff --git a/scripts/package-barc-preview.sh b/scripts/package-barc-preview.sh index 82b1e44..8a2dd29 100755 --- a/scripts/package-barc-preview.sh +++ b/scripts/package-barc-preview.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" usage() { echo "usage: $0 " >&2 @@ -19,6 +20,7 @@ required_directories=( src/Broker.Browser.Contracts/generated src/Broker.Browser.Wasm ) +python3 "$repo_root/scripts/check-barc-shared-closure.py" "$source_root" for relative in "${required_directories[@]}"; do [[ -d "$source_root/$relative" ]] || { echo "joined BARC source is incomplete: missing $relative" >&2 @@ -61,6 +63,11 @@ copy_tree() { for relative in "${required_directories[@]}"; do copy_tree "$relative" done +mkdir -p "$payload/src/Broker.Browser.SharedWasm" +install -m 0644 "$source_root/src/Broker.Browser.SharedWasm/index.js" "$payload/src/Broker.Browser.SharedWasm/index.js" +for relative in fable _content; do + cp -r "$source_root/src/Broker.Browser.SharedWasm/$relative" "$payload/src/Broker.Browser.SharedWasm/" +done install -m 0644 "$guest_root/manual-preview.wasm" "$payload/guests/manual-preview.wasm" install -m 0644 "$guest_root/custom-preview.wasm" "$payload/guests/custom-preview.wasm" @@ -72,11 +79,13 @@ fi ( cd "$payload" find src/Broker.Browser.Client src/Broker.Browser.Contracts/generated \ - src/Broker.Browser.Wasm guests -type f -print0 \ + src/Broker.Browser.Wasm src/Broker.Browser.SharedWasm guests -type f -print0 \ | LC_ALL=C sort -z \ | xargs -0 sha256sum > BARC-PREVIEW.SHA256 ) +python3 "$repo_root/scripts/check-barc-shared-closure.py" "$payload" --manifest + mkdir -p "$(dirname "$output")" tar -C "$payload" --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner \ -czf "$output" BARC-PREVIEW.SHA256 guests src diff --git a/scripts/qualify-barc-receiver-journey.sh b/scripts/qualify-barc-receiver-journey.sh index 7032273..a1c0337 100755 --- a/scripts/qualify-barc-receiver-journey.sh +++ b/scripts/qualify-barc-receiver-journey.sh @@ -46,6 +46,10 @@ for relative in src/Broker.Browser.Wasm src/Broker.Browser.Contracts; do mkdir -p "$evidence/companion-assets/$(dirname "$relative")" tar -C "$archive_root" -cf - "$relative" | tar -C "$evidence/companion-assets" -xf - done +if [[ -d "$archive_root/src/Broker.Browser.SharedWasm" ]]; then + python3 "$product_root/scripts/check-barc-shared-closure.py" "$archive_root" --manifest + tar -C "$archive_root" -cf - src/Broker.Browser.SharedWasm | tar -C "$evidence/companion-assets" -xf - +fi install -m 0644 "$archive_root/guests/manual-preview.wasm" "$evidence/companion-assets/guests/manual-preview.wasm" install -m 0644 "$archive_root/guests/custom-preview.wasm" "$evidence/companion-assets/guests/custom-preview.wasm" diff --git a/scripts/qualify-barc-receiver-live-prep.sh b/scripts/qualify-barc-receiver-live-prep.sh index 8f7837f..357b028 100755 --- a/scripts/qualify-barc-receiver-live-prep.sh +++ b/scripts/qualify-barc-receiver-live-prep.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" usage() { echo "usage: $0 --product-root --archive --pins --evidence " >&2 @@ -38,7 +39,10 @@ jq -e ' (has("queueEvidenceScheme") | not)) or (.schema == "fsbar.barc-live-receiver-pins/v3" and .profile == "barc-live-tactical-stock-v1" and .protocolVersion == 2 and .tacticalRevision == 2 and .guestAbiVersion == 1 and - .queueEvidenceScheme == 2)) and + .queueEvidenceScheme == 2) or + (.schema == "fsbar.barc-live-receiver-pins/v4" and .profile == "barc-live-tactical-stock-v1" and + .protocolVersion == 2 and .tacticalRevision == 2 and .guestAbiVersion == 1 and + .queueEvidenceScheme == 2 and .runtime == "shared-wasm-0.2.0")) and (.source.commit | test("^[0-9a-f]{40}$")) and (.files | type == "array" and length >= 7) and ([.files[] | select(.role != "dependency") | .role] | sort == ["clientCss","clientJs","codec","contract","customGuest","manualGuest","worker"]) and @@ -54,15 +58,26 @@ jq -e ' (.archivePath | startswith("src/") or startswith("guests/"))) and (.files[] | select(.role == "clientJs") | .archivePath) == "src/Broker.Browser.Client/dist/assets/barc-preview.js" and (.files[] | select(.role == "clientCss") | .archivePath) == "src/Broker.Browser.Client/dist/assets/barc-preview.css" and - (.files[] | select(.role == "worker") | .archivePath) == "src/Broker.Browser.Wasm/guest-worker.js" and + (.files[] | select(.role == "worker") | .archivePath) == + (if .schema == "fsbar.barc-live-receiver-pins/v4" then + "src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/module-worker.mjs" + else "src/Broker.Browser.Wasm/guest-worker.js" end) and (.files[] | select(.role == "codec") | .archivePath) == "src/Broker.Browser.Contracts/generated/codec.js" and (.files[] | select(.role == "contract") | .archivePath) == "src/Broker.Browser.Contracts/generated/barc_browser.js" and - (. as $pins | all([ + (. as $pins | all((if .schema == "fsbar.barc-live-receiver-pins/v4" then [ + "src/Broker.Browser.Wasm/barc-wire.js", + "src/Broker.Browser.Wasm/index.js", + "src/Broker.Browser.SharedWasm/index.js", + "src/Broker.Browser.SharedWasm/fable/Adapter.js", + "src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/worker-client.mjs", + "src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/policy/Host.js", + "src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js" + ] else [ "src/Broker.Browser.Wasm/barc-wire.js", "src/Broker.Browser.Wasm/wasm-profile.js", "src/Broker.Browser.Wasm/guest-supervisor.js", "src/Broker.Browser.Wasm/index.js" - ][]; . as $path | any($pins.files[]; + ] end)[]; . as $path | any($pins.files[]; .role == "dependency" and .sourcePath == $path and .archivePath == $path))) and all(.files[] | select(.role == "manualGuest" or .role == "customGuest"); .archivePath | test("^guests/[^/]+\\.wasm$")) ' "$pins" >/dev/null || { echo "invalid or incomplete live receiver pins" >&2; exit 2; } @@ -126,6 +141,11 @@ while IFS=$'\t' read -r source_path archive_path expected_sha; do } done < <(jq -r '.files[] | [.sourcePath,.archivePath,.sha256] | @tsv' "$pins") +if [[ "$(jq -r '.schema' "$pins")" == fsbar.barc-live-receiver-pins/v4 ]]; then + python3 "$repo_root/scripts/check-barc-shared-closure.py" "$product_root" --pins "$pins" + python3 "$repo_root/scripts/check-barc-shared-closure.py" "$evidence" --pins "$pins" +fi + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" adapter="$repo_root/examples/barc-fable-game/adapter/Client/barc-receiver.js" grep -F 'barc-live-v1' "$adapter" >/dev/null diff --git a/scripts/qualify-barc-receiver.sh b/scripts/qualify-barc-receiver.sh index dc8fd36..b163e5e 100755 --- a/scripts/qualify-barc-receiver.sh +++ b/scripts/qualify-barc-receiver.sh @@ -138,6 +138,16 @@ if [[ "$scaffold_only" == false ]]; then src/Broker.Browser.Client/dist/assets/barc-preview.css \ src/Broker.Browser.Wasm/guest-worker.js ) + worker_path=src/Broker.Browser.Wasm/guest-worker.js + shared_root="$evidence/receiver/Client/public/barc-preview/src/Broker.Browser.SharedWasm" + if [[ -d "$shared_root" ]]; then + python3 "$repo_root/scripts/check-barc-shared-closure.py" "$evidence/receiver/Client/public/barc-preview" --manifest + worker_path=src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/module-worker.mjs + asset_paths[2]="$worker_path" + while IFS= read -r asset; do asset_paths+=("$asset"); done < <( + find "$shared_root" -type f -printf 'src/Broker.Browser.SharedWasm/%P\n' | LC_ALL=C sort + ) + fi live_pins="$evidence/receiver/Client/public/barc-preview/src/BARC-LIVE-PINS.json" if [[ -f "$live_pins" ]]; then receiver_profile="$(jq -r '.profile' "$live_pins")" @@ -167,7 +177,7 @@ if [[ "$scaffold_only" == false ]]; then manifest="$evidence/receiver/Client/public/barc-preview/BARC-PREVIEW.SHA256" client_js_sha="$(awk '$2 == "src/Broker.Browser.Client/dist/assets/barc-preview.js" { print $1 }' "$manifest")" client_css_sha="$(awk '$2 == "src/Broker.Browser.Client/dist/assets/barc-preview.css" { print $1 }' "$manifest")" - worker_sha="$(awk '$2 == "src/Broker.Browser.Wasm/guest-worker.js" { print $1 }' "$manifest")" + worker_sha="$(awk -v path="$worker_path" '$2 == path { print $1 }' "$manifest")" manual_sha="$(awk -v path="$manual_path" '$2 == path { print $1 }' "$manifest")" custom_sha="$(awk -v path="$custom_path" '$2 == path { print $1 }' "$manifest")" fi diff --git a/src/Broker.Browser.Client/live-runtime.js b/src/Broker.Browser.Client/live-runtime.js index 268c606..587215a 100644 --- a/src/Broker.Browser.Client/live-runtime.js +++ b/src/Broker.Browser.Client/live-runtime.js @@ -206,7 +206,7 @@ export function createLiveRuntime(root, options, emit) { if (!(root instanceof Element)) throw new Error("BARC live mount root must be an Element"); const assetBase = new URL(options.assetBaseUrl); if (!assetBase.href.endsWith("/")) throw new Error("assetBaseUrl must end with /"); const requestedProfile = options.profile ?? LEGACY_PROFILE, tacticalProfile = isTacticalProfile(requestedProfile), stockTacticalProfile = requestedProfile === STOCK_TACTICAL_PROFILE; - const supervisor = new GuestSupervisor({ workerUrl: new URL("src/Broker.Browser.Wasm/guest-worker.js", assetBase), limits: { phaseTimeoutMilliseconds: 250 } }); + const supervisor = new GuestSupervisor({ assetBase: new URL("src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/", assetBase), limits: { phaseTimeoutMilliseconds: 250 } }); let socket = null, disposed = false, pairedSession = null, bootstrap = null, observation = null, moduleBytes = null, moduleName = "No guest loaded", moduleIdentity = null; let selected = [], target = { x: 0, z: 0 }, attackTarget = null, friendlyTarget = null, featureTarget = null, movePolicy = "MOVE_POLICY_REPLACE", controllerStage = "CONTROLLER_STAGE_UNSPECIFIED"; let connectionGeneration = 0, lifecycleEpoch = 0, composing = false, pointerId = null, pointerUnit = null, pointerCycle = null, selectionCursor = -1, protocolRefused = false; diff --git a/src/Broker.Browser.Client/package.json b/src/Broker.Browser.Client/package.json index 03e1286..8a15b2b 100644 --- a/src/Broker.Browser.Client/package.json +++ b/src/Broker.Browser.Client/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "compile": "dotnet fable Broker.Browser.Client.fsproj --outDir output --noCache", + "compile": "bash ../../tests/Broker.Browser.SharedWasm.Tests/build.sh --deploy ../Broker.Browser.SharedWasm && dotnet fable Broker.Browser.Client.fsproj --outDir output --noCache", "build": "npm run compile && vite build", "dev": "npm run compile && vite --host 127.0.0.1" }, diff --git a/src/Broker.Browser.Client/runtime.js b/src/Broker.Browser.Client/runtime.js index e7d1f8a..63da485 100644 --- a/src/Broker.Browser.Client/runtime.js +++ b/src/Broker.Browser.Client/runtime.js @@ -178,7 +178,7 @@ function createPreviewRuntime(root, options, emit) { if (!(root instanceof Element)) throw new Error("BARC preview mount root must be an Element"); const assetBase = new URL(options.assetBaseUrl); if (!assetBase.href.endsWith("/")) throw new Error("assetBaseUrl must end with /"); - const supervisor = new GuestSupervisor({ workerUrl: new URL("src/Broker.Browser.Wasm/guest-worker.js", assetBase) }); + const supervisor = new GuestSupervisor({ assetBase: new URL("src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/", assetBase) }); let socket = null; let disposed = false; let bootstrap = null; diff --git a/src/Broker.Browser.Wasm/index.js b/src/Broker.Browser.Wasm/index.js index 9d5e35e..d315e91 100644 --- a/src/Broker.Browser.Wasm/index.js +++ b/src/Broker.Browser.Wasm/index.js @@ -1,3 +1,3 @@ -export { GuestSupervisor, DEFAULT_LIMITS } from "./guest-supervisor.js"; +export { GuestSupervisor, DEFAULT_LIMITS } from "../Broker.Browser.SharedWasm/index.js"; export { validateGuestModule } from "./wasm-profile.js"; export { requestIdentity, validateGuestResponse } from "./barc-wire.js"; diff --git a/tests/Broker.Browser.Client.Tests/client.spec.js b/tests/Broker.Browser.Client.Tests/client.spec.js index 32b8f2d..b55744c 100644 --- a/tests/Broker.Browser.Client.Tests/client.spec.js +++ b/tests/Broker.Browser.Client.Tests/client.spec.js @@ -14,6 +14,7 @@ let server, port, lastAuth, lastSocket; const routes = [ ["/client/", "src/Broker.Browser.Client/dist/"], + ["/src/Broker.Browser.SharedWasm/", "src/Broker.Browser.SharedWasm/"], ["/src/Broker.Browser.Wasm/", "src/Broker.Browser.Wasm/"], ["/guests/", "tests/Broker.Browser.Wasm.Tests/generated/"] ]; @@ -25,7 +26,7 @@ test.beforeAll(async () => { const route = routes.find(([prefix]) => request.url.startsWith(prefix)); if (!route) { response.statusCode = 404; response.end(); return; } const path = resolve(root, route[1], request.url.slice(route[0].length)); - response.setHeader("content-type", extname(path) === ".js" ? "text/javascript" : extname(path) === ".css" ? "text/css" : "application/wasm"); + response.setHeader("content-type", [".js", ".mjs"].includes(extname(path)) ? "text/javascript" : extname(path) === ".css" ? "text/css" : "application/wasm"); response.end(await readFile(path)); } catch { response.statusCode = 404; response.end(); } }); @@ -66,6 +67,7 @@ test("visible pairing, real guest, and pointer/keyboard inputs stay read-only", await expect(page.locator(".module")).toContainText("staged"); await page.getByRole("button", { name: "Rearm" }).click(); await expect(page.locator(".module")).toContainText("armed at guest generation"); + expect(page.workers().map(worker => new URL(worker.url()).pathname)).toEqual(["/src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/module-worker.mjs"]); const own = page.locator('[data-unit-id="77"]'); await own.click(); diff --git a/tests/Broker.Browser.Client.Tests/harness.html b/tests/Broker.Browser.Client.Tests/harness.html index 471b506..f7ed3e0 100644 --- a/tests/Broker.Browser.Client.Tests/harness.html +++ b/tests/Broker.Browser.Client.Tests/harness.html @@ -1,2 +1,2 @@ BARC client test -
+
diff --git a/tests/Broker.Browser.Client.Tests/live-client.spec.js b/tests/Broker.Browser.Client.Tests/live-client.spec.js index 1c699e0..31ca30f 100644 --- a/tests/Broker.Browser.Client.Tests/live-client.spec.js +++ b/tests/Broker.Browser.Client.Tests/live-client.spec.js @@ -29,10 +29,10 @@ const feature0={reference:{id:"0",lifetime:"9007199254741019"},definitionId:91,p const tacticalObservation={...fullObservation,tactical:{catalogueId,catalogueRevision,economy:{perspectiveId:"team-0",sampleFrame:basis.nativeFrame,metal:{resourceName:"metal",unit:"resource",current:500,storage:1000,incomePerSecond:8.5,usagePerSecond:4},energy:{resourceName:"energy",unit:"resource",current:2500,storage:5000}},actors:[actorTactical(ref0),actorTactical(ref31999)],features:[feature0]}}; const stockObservation={...tacticalObservation,tactical:{...tacticalObservation.tactical,actors:tacticalObservation.tactical.actors.map(actor=>({...actor,queue:actor.queue.map(queue=>queue.complete?{...queue,evidenceScheme:"NATIVE_QUEUE_EVIDENCE_SCHEME_STOCK_LUA_SUPPORTED_FIELDS_V1"}:{domain:queue.domain,revision:"0",entries:[],complete:false,evidenceScheme:"NATIVE_QUEUE_EVIDENCE_SCHEME_STOCK_LUA_SUPPORTED_FIELDS_V1"})}))}}; let server, port, sockets, submissions, armRequests, auth, canonicalFeedback, heldFeedback, resultSequence, revocations, tacticalBootstrapOverride, tacticalObservationOverride, observationDelayMs; -const routes=[["/client/","src/Broker.Browser.Client/dist/"],["/src/Broker.Browser.Wasm/","src/Broker.Browser.Wasm/"],["/guests/","tests/Broker.Browser.Wasm.Tests/generated/"]]; +const routes=[["/client/","src/Broker.Browser.Client/dist/"],["/src/Broker.Browser.SharedWasm/","src/Broker.Browser.SharedWasm/"],["/src/Broker.Browser.Wasm/","src/Broker.Browser.Wasm/"],["/guests/","tests/Broker.Browser.Wasm.Tests/generated/"]]; test.beforeAll(async()=>{ - server=createServer(async(request,response)=>{try{if(request.url.startsWith("/?")){response.setHeader("content-type","text/html");response.end(await readFile(new URL("./harness.html",import.meta.url)));return}const route=routes.find(([prefix])=>request.url.startsWith(prefix));if(!route){response.statusCode=404;response.end();return}const path=resolve(root,route[1],request.url.slice(route[0].length));response.setHeader("content-type",extname(path)===".js"?"text/javascript":extname(path)===".css"?"text/css":"application/wasm");response.end(await readFile(path))}catch{response.statusCode=404;response.end()}}); + server=createServer(async(request,response)=>{try{if(request.url.startsWith("/?")){response.setHeader("content-type","text/html");response.end(await readFile(new URL("./harness.html",import.meta.url)));return}const route=routes.find(([prefix])=>request.url.startsWith(prefix));if(!route){response.statusCode=404;response.end();return}const path=resolve(root,route[1],request.url.slice(route[0].length));response.setHeader("content-type",[".js",".mjs"].includes(extname(path))?"text/javascript":extname(path)===".css"?"text/css":"application/wasm");response.end(await readFile(path))}catch{response.statusCode=404;response.end()}}); sockets=new WebSocketServer({noServer:true});server.on("upgrade",(request,socket,head)=>sockets.handleUpgrade(request,socket,head,ws=>sockets.emit("connection",ws))); sockets.on("connection",ws=>{ws.once("message",raw=>{ auth=canonicalObject(v1.LiveClientEnvelope,raw);let currentController=controller,activeModule=null,stateSequence=0; diff --git a/tests/Broker.Browser.Receiver.Tests/adoption.test.sh b/tests/Broker.Browser.Receiver.Tests/adoption.test.sh index 5358b22..a0f1f3d 100755 --- a/tests/Broker.Browser.Receiver.Tests/adoption.test.sh +++ b/tests/Broker.Browser.Receiver.Tests/adoption.test.sh @@ -2,6 +2,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$repo_root/tests/Broker.Browser.Receiver.Tests/shared-fixture.sh" clean_receiver="${1:?path to a freshly generated public receiver required}" candidate_archive="${2:-}" work="$(mktemp -d "${TMPDIR:-/tmp}/barc-receiver-adoption-test.XXXXXX")" @@ -31,6 +32,7 @@ if [[ -z "$candidate_archive" ]]; then printf '%s\n' 'self.onmessage = () => {};' > "$source/src/Broker.Browser.Wasm/guest-worker.js" printf '\x00\x61\x73\x6d\x01\x00\x00\x00\x00\x00' > "$source/tests/Broker.Browser.Wasm.Tests/generated/manual-preview.wasm" printf '\x00\x61\x73\x6d\x01\x00\x00\x00\x00\x00' > "$source/tests/Broker.Browser.Wasm.Tests/generated/custom-preview.wasm" + shared_fixture "$source" candidate_archive="$work/barc-preview.tar.gz" "$repo_root/scripts/package-barc-preview.sh" "$source" "$candidate_archive" >/dev/null else diff --git a/tests/Broker.Browser.Receiver.Tests/live-preparation.test.sh b/tests/Broker.Browser.Receiver.Tests/live-preparation.test.sh index ac9c7bc..596c1db 100755 --- a/tests/Broker.Browser.Receiver.Tests/live-preparation.test.sh +++ b/tests/Broker.Browser.Receiver.Tests/live-preparation.test.sh @@ -164,6 +164,38 @@ if "$repo_root/scripts/package-barc-live-receiver.sh" --source "$source" --pins exit 1 fi +# Explicit shared successor: legacy stock pins above retain their own qualification. +source "$repo_root/tests/Broker.Browser.Receiver.Tests/shared-fixture.sh" +shared_fixture "$source" +git -C "$source" add src/Broker.Browser.SharedWasm src/Broker.Browser.Wasm +git -C "$source" -c user.name='Receiver fixture' -c user.email='fixture@example.invalid' commit -qm 'shared receiver fixture' +shared_pins="$work/shared-pins.json" +python3 - "$source" "$stock_pins" "$shared_pins" <<'PY' +import hashlib, json, pathlib, subprocess, sys +root = pathlib.Path(sys.argv[1]); pins = json.loads(pathlib.Path(sys.argv[2]).read_text()) +pins.update(schema='fsbar.barc-live-receiver-pins/v4', runtime='shared-wasm-0.2.0') +pins['source']['commit'] = subprocess.check_output(['git','-C',str(root),'rev-parse','HEAD'],text=True).strip() +pins['files'] = [entry for entry in pins['files'] if entry['role'] != 'worker' and not entry['archivePath'].startswith('src/Broker.Browser.Wasm/')] +paths = list((root/'src/Broker.Browser.SharedWasm').rglob('*')) +paths += [root/'src/Broker.Browser.Wasm'/name for name in ('index.js','barc-wire.js','wasm-profile.js','guest-worker.js','guest-supervisor.js')] +for path in sorted(paths): + if path.is_file(): + relative = path.relative_to(root).as_posix() + role = 'worker' if relative.endswith('/module-worker.mjs') else 'dependency' + pins['files'].append(dict(role=role,sourcePath=relative,archivePath=relative,sha256=hashlib.sha256(path.read_bytes()).hexdigest())) +pathlib.Path(sys.argv[3]).write_text(json.dumps(pins)+'\n') +PY +"$repo_root/scripts/package-barc-live-receiver.sh" --source "$source" --pins "$shared_pins" --output "$work/shared.tar.gz" >/dev/null +"$repo_root/scripts/qualify-barc-receiver-live-prep.sh" --product-root "$source" --pins "$shared_pins" --archive "$work/shared.tar.gz" --evidence "$work/shared-evidence" >/dev/null +jq 'del(.files[] | select(.archivePath | endswith("/Owned.js")))' "$shared_pins" > "$work/incomplete-shared-pins.json" +if "$repo_root/scripts/package-barc-live-receiver.sh" --source "$source" --pins "$work/incomplete-shared-pins.json" --output "$work/incomplete-shared.tar.gz" >/dev/null 2>&1; then + echo "shared packager accepted an unpinned transitive policy asset" >&2; exit 1 +fi +jq '(.files[] | select(.archivePath | endswith("/Owned.js")) | .sha256) = ("0" * 64)' "$shared_pins" > "$work/changed-shared-pins.json" +if "$repo_root/scripts/package-barc-live-receiver.sh" --source "$source" --pins "$work/changed-shared-pins.json" --output "$work/changed-shared.tar.gz" >/dev/null 2>&1; then + echo "shared packager accepted changed transitive policy bytes" >&2; exit 1 +fi + grep -F 'searchParams.get("barc-profile")' "$repo_root/examples/barc-fable-game/adapter/Client/barc-receiver.js" >/dev/null grep -F 'profile === "barc-live-v1"' "$repo_root/examples/barc-fable-game/adapter/Client/barc-receiver.js" >/dev/null grep -F 'profile === "barc-live-tactical-v1"' "$repo_root/examples/barc-fable-game/adapter/Client/barc-receiver.js" >/dev/null diff --git a/tests/Broker.Browser.Receiver.Tests/package-archive.test.sh b/tests/Broker.Browser.Receiver.Tests/package-archive.test.sh index f35be37..889df88 100755 --- a/tests/Broker.Browser.Receiver.Tests/package-archive.test.sh +++ b/tests/Broker.Browser.Receiver.Tests/package-archive.test.sh @@ -2,6 +2,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$repo_root/tests/Broker.Browser.Receiver.Tests/shared-fixture.sh" work="$(mktemp -d "${TMPDIR:-/tmp}/barc-receiver-package-test.XXXXXX")" cleanup() { rm -rf -- "$work"; } trap cleanup EXIT @@ -18,6 +19,8 @@ printf '%s\n' 'self.onmessage = () => {};' > "$work/source/src/Broker.Browser.Wa printf '\x00\x61\x73\x6d\x01\x00\x00\x00\x00\x00' > "$work/source/tests/Broker.Browser.Wasm.Tests/generated/manual-preview.wasm" printf '\x00\x61\x73\x6d\x01\x00\x00\x00\x00\x00' > "$work/source/tests/Broker.Browser.Wasm.Tests/generated/custom-preview.wasm" +shared_fixture "$work/source" + "$repo_root/scripts/package-barc-preview.sh" "$work/source" "$work/first.tar.gz" >/dev/null "$repo_root/scripts/package-barc-preview.sh" "$work/source" "$work/second.tar.gz" >/dev/null cmp "$work/first.tar.gz" "$work/second.tar.gz" @@ -38,7 +41,18 @@ printf '%s\n' \ src/Broker.Browser.Client/BarcPreview.fs \ src/Broker.Browser.Client/dist/index.js \ src/Broker.Browser.Contracts/generated/barc_browser.js \ - src/Broker.Browser.Wasm/guest-worker.js > "$expected" + src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/module-worker.mjs \ + src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/policy/Host.js \ + src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/policy/Owned.js \ + src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js \ + src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/worker-client.mjs \ + src/Broker.Browser.SharedWasm/fable/Adapter.js \ + src/Broker.Browser.SharedWasm/index.js \ + src/Broker.Browser.Wasm/barc-wire.js \ + src/Broker.Browser.Wasm/guest-supervisor.js \ + src/Broker.Browser.Wasm/guest-worker.js \ + src/Broker.Browser.Wasm/index.js \ + src/Broker.Browser.Wasm/wasm-profile.js | LC_ALL=C sort > "$expected" find "$work/unpacked" -type f -printf '%P\n' | LC_ALL=C sort > "$actual" diff -u "$expected" "$actual" @@ -47,4 +61,109 @@ if "$repo_root/scripts/package-barc-preview.sh" "$work/source" "$work/first.tar. exit 1 fi +# Removing a transitive policy dependency must refuse before any archive output. +rm "$work/source/src/Broker.Browser.SharedWasm/_content/FS.GG.Wasm.Browser/policy/Owned.js" +if "$repo_root/scripts/package-barc-preview.sh" "$work/source" "$work/missing-policy.tar.gz" >/dev/null 2>&1; then + echo "packager accepted an incomplete shared Worker graph" >&2; exit 1 +fi +[[ ! -e "$work/missing-policy.tar.gz" ]] +# An added archive file outside the original manifest cannot pass adoption guards. +printf '%s\n' 'unlisted' > "$work/unpacked/src/unlisted.js" +if python3 "$repo_root/scripts/check-barc-shared-closure.py" "$work/unpacked" --manifest >/dev/null 2>&1; then + echo "shared closure accepted an unmanifested file" >&2; exit 1 +fi +# Complete Fable libraries retain unused Node-only files. They must remain in +# the archive inventory without making them browser runtime entrypoints. +shared_fixture "$work/source" +shared="$work/source/src/Broker.Browser.SharedWasm" +printf '%s\n' 'import { readFileSync } from "node:fs";' > "$shared/fable/UnusedNode.js" +python3 "$repo_root/scripts/check-barc-shared-closure.py" "$work/source" +"$repo_root/scripts/package-barc-preview.sh" "$work/source" "$work/dormant-library.tar.gz" >/dev/null +tar -tzf "$work/dormant-library.tar.gz" | grep -Fx 'src/Broker.Browser.SharedWasm/fable/UnusedNode.js' >/dev/null +printf '%s\n' 'import "./UnusedNode.js";' >> "$shared/fable/Adapter.js" +if python3 "$repo_root/scripts/check-barc-shared-closure.py" "$work/source" > "$work/reachable-node.log" 2>&1; then + echo "shared closure accepted a reachable Node-only runtime import" >&2; exit 1 +fi +grep -F 'nonrelative shared runtime import: node:fs' "$work/reachable-node.log" >/dev/null +rm "$shared/fable/UnusedNode.js" +shared_fixture "$work/source" +# Exercise complete import operands through the actual closure guard, including +# comments, escapes and template substitutions. No JavaScript is executed. +python3 - "$repo_root/scripts/check-barc-shared-closure.py" "$work/source" <<'PYIMPORTS' +import pathlib, subprocess, sys +guard, root = sys.argv[1:] +adapter = pathlib.Path(root) / 'src/Broker.Browser.SharedWasm/fable/Adapter.js' +original = adapter.read_text() +target = '../_content/FS.GG.Wasm.Browser/policy/Host.js' +accepted = [ + 'import(' + repr(target) + ');', + 'import /* separator */ ( /* operand */ ' + repr(target) + ' /* end */ );', + 'import /* binding */ { Host } /* source */ from ' + repr(target) + ';', + 'export /* binding */ { Host } /* source */ from ' + repr(target) + ';', + 'const note = "import(selectedPath)"; // import(otherPath)\n', + 'const pattern = /import[(]selectedPath[)]/;', + 'const note = `import(selectedPath)`;', + 'const note = `${import(' + repr(target) + ')}`;', +] +rejected = [ + 'import(' + repr(target) + ' + suffix);', + 'import /* separator */ (selectedPath);', + 'import(' + repr(target) + '\n + suffix);', + 'import((' + repr(target) + '));', + 'import(`' + target + '`);', + 'import(' + repr(target) + ', options);', + 'import(' + repr(target) + ' ?? fallback);', + 'const note = `${import(selectedPath)}`;', + 'import("../_content/FS.GG.Wasm.Browser/policy/Host\\x2ejs");', + 'import("../_content/FS.GG.Wasm.Browser/policy/Host\\u002ejs");', + 'import /* separator */ "node:fs";', + 'export /* separator */ * from "node:fs";', + 'const value = counter++ / import(selectedPath) / divisor;', + 'const value = counter-- / import(selectedPath) / divisor;', + 'const value = object.return / import(selectedPath) / divisor;', + 'const value = object.if() / import(selectedPath) / divisor;', + '// ordinary comment\rimport(selectedPath);', + '// ordinary comment\u2028import(selectedPath);', + '// ordinary comment\u2029import(selectedPath);', + 'if (false) {} else /[/*]/; import(selectedPath);', + 'export default /[/*]/; import(selectedPath);', + 'function check() { label: while (true) { break label\n/[/*]/; import(selectedPath); } }', + 'const of = counter; const value = of / import(selectedPath) / divisor;', +] +try: + for expected, cases in [(True, accepted), (False, rejected)]: + for statement in cases: + adapter.write_text(original + '\n' + statement + '\n') + result = subprocess.run([sys.executable, guard, root], capture_output=True, text=True, timeout=10) + if (result.returncode == 0) != expected: + raise SystemExit('unexpected import-guard disposition: ' + repr(statement) + ': ' + result.stderr) +finally: + adapter.write_text(original) +PYIMPORTS +# Individual files below 8MiB must still obey the aggregate inspection budget. +shared_fixture "$work/source" +python3 - "$work/source/src/Broker.Browser.SharedWasm/_content" <<'PYBYTES' +import pathlib, sys +root = pathlib.Path(sys.argv[1]) +for index in range(5): + with (root / ('bounded-' + str(index) + '.bin')).open('wb') as stream: + stream.truncate(7 * 1024 * 1024) +PYBYTES +if python3 "$repo_root/scripts/check-barc-shared-closure.py" "$work/source" > "$work/byte-bound.log" 2>&1; then + echo "shared closure accepted an excessive aggregate inspection population" >&2; exit 1 +fi +grep -F 'aggregate inspection byte bound exceeded' "$work/byte-bound.log" >/dev/null +rm "$work/source/src/Broker.Browser.SharedWasm/_content/"bounded-*.bin +# Traversal must refuse excessive empty directories before reading their contents. +shared_fixture "$work/source" +python3 - "$work/source/src/Broker.Browser.SharedWasm/fable" <<'PYBOUND' +import pathlib, sys +root = pathlib.Path(sys.argv[1]) +for index in range(257): + (root / ('empty-' + str(index))).mkdir() +PYBOUND +if python3 "$repo_root/scripts/check-barc-shared-closure.py" "$work/source" > "$work/directory-bound.log" 2>&1; then + echo "shared closure accepted unbounded empty-directory traversal" >&2; exit 1 +fi +grep -F 'directory population/depth bound exceeded' "$work/directory-bound.log" >/dev/null printf '%s\n' 'receiver archive package test: passed' diff --git a/tests/Broker.Browser.Receiver.Tests/shared-fixture.sh b/tests/Broker.Browser.Receiver.Tests/shared-fixture.sh new file mode 100644 index 0000000..b6c38e6 --- /dev/null +++ b/tests/Broker.Browser.Receiver.Tests/shared-fixture.sh @@ -0,0 +1,17 @@ +# Inert deployed assets for archive integrity controls; no guest/browser runs. +shared_fixture() { + local source="$1" shared="$1/src/Broker.Browser.SharedWasm" + mkdir -p "$shared/fable" "$shared/_content/FS.GG.Wasm.Browser/policy" "$source/src/Broker.Browser.Wasm" + printf '%s\n' 'export { GuestSupervisor } from "../Broker.Browser.SharedWasm/index.js";' > "$source/src/Broker.Browser.Wasm/index.js" + printf '%s\n' 'export {};' > "$source/src/Broker.Browser.Wasm/guest-worker.js" + printf '%s\n' 'export {};' > "$source/src/Broker.Browser.Wasm/guest-supervisor.js" + printf '%s\n' 'export {};' > "$source/src/Broker.Browser.Wasm/barc-wire.js" + printf '%s\n' 'export {};' > "$source/src/Broker.Browser.Wasm/wasm-profile.js" + printf '%s\n' 'import "./fable/Adapter.js"; import "./_content/FS.GG.Wasm.Browser/worker-client.mjs"; export class GuestSupervisor {}' > "$shared/index.js" + printf '%s\n' 'import "../_content/FS.GG.Wasm.Browser/policy/Host.js";' > "$shared/fable/Adapter.js" + printf '%s\n' 'export {};' > "$shared/_content/FS.GG.Wasm.Browser/worker-client.mjs" + printf '%s\n' 'import "./policy/WorkerEntry.js";' > "$shared/_content/FS.GG.Wasm.Browser/module-worker.mjs" + printf '%s\n' 'import "./Owned.js";' > "$shared/_content/FS.GG.Wasm.Browser/policy/Host.js" + printf '%s\n' 'import "./Owned.js";' > "$shared/_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js" + printf '%s\n' 'export {};' > "$shared/_content/FS.GG.Wasm.Browser/policy/Owned.js" +} diff --git a/tests/Broker.Browser.SharedWasm.Tests/build.sh b/tests/Broker.Browser.SharedWasm.Tests/build.sh index a4cc5fe..22d554d 100755 --- a/tests/Broker.Browser.SharedWasm.Tests/build.sh +++ b/tests/Broker.Browser.SharedWasm.Tests/build.sh @@ -6,6 +6,14 @@ project="$root/src/Broker.Browser.SharedWasm" export NUGET_PACKAGES="$here/.nuget" export NUGET_HTTP_CACHE_PATH="$here/.nuget-http" app="$here/public/sub/app" +deploy= +if [[ $# -gt 0 ]]; then + [[ $# -eq 2 && "$1" == --deploy ]] || { echo "usage: $0 [--deploy ]" >&2; exit 64; } + deploy="$(realpath -e "$2")" + [[ "$deploy" == "$project" ]] || { echo "deployment must be the joined SharedWasm source directory" >&2; exit 2; } +fi +# Regenerate both trees together; stale package assets must not survive a build. +rm -rf -- "$app/src/Broker.Browser.SharedWasm/fable" "$app/src/Broker.Browser.SharedWasm/_content" "$app/_content" dotnet restore "$project/Broker.Browser.SharedWasm.fsproj" --configfile "$project/NuGet.Config" --locked-mode --disable-parallel dotnet build "$project/Broker.Browser.SharedWasm.fsproj" --no-restore -m:1 -nr:false -p:UseSharedCompilation=false (cd "$project"; dotnet tool restore --configfile NuGet.Config; dotnet fable Broker.Browser.SharedWasm.fsproj --noRestore --outDir "$app/src/Broker.Browser.SharedWasm/fable") @@ -18,3 +26,10 @@ cp "$here/harness.html" "$app/" mkdir -p "$here/.codec" cp "$root/src/Broker.Browser.Contracts/generated/"*.js "$here/.codec/" + +if [[ -n "$deploy" ]]; then + rm -rf -- "$deploy/fable" "$deploy/_content" + cp -r "$app/src/Broker.Browser.SharedWasm/fable" "$deploy/fable" + cp -r "$app/src/Broker.Browser.SharedWasm/_content" "$deploy/_content" + python3 "$root/scripts/check-barc-shared-closure.py" "$root" +fi diff --git a/tests/Broker.Browser.Wasm.Tests/harness.html b/tests/Broker.Browser.Wasm.Tests/harness.html index c3f4acc..f85f494 100644 --- a/tests/Broker.Browser.Wasm.Tests/harness.html +++ b/tests/Broker.Browser.Wasm.Tests/harness.html @@ -5,7 +5,7 @@ 0