From bd04b070ce27b40b20a4cf23ce1668839a258295 Mon Sep 17 00:00:00 2001 From: EHotwagner Date: Mon, 12 Oct 2026 11:29:26 +0200 Subject: [PATCH] Prepare immutable package-only external reference receiver --- .../external-reference-template-package.py | 178 ++++++++++++++++++ ...est-external-reference-template-package.py | 157 +++++++++++++++ .../verify-external-reference-candidate.sh | 86 +++++++-- 3 files changed, 403 insertions(+), 18 deletions(-) create mode 100644 tests/composition/fable-game/external-reference-template-package.py create mode 100644 tests/composition/fable-game/test-external-reference-template-package.py diff --git a/tests/composition/fable-game/external-reference-template-package.py b/tests/composition/fable-game/external-reference-template-package.py new file mode 100644 index 00000000..7fbf0060 --- /dev/null +++ b/tests/composition/fable-game/external-reference-template-package.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Inspect an immutable external Templates input; never pack, install or fetch.""" +from pathlib import Path, PurePosixPath +import hashlib +import io +import importlib.util +import json +import re +import stat +import sys +from xml.etree import ElementTree as ET +from zipfile import ZipFile + +PACKAGE_ID = 'FS.GG.Workspace.Template' +PACKAGE_VERSION = '0.18.3' + + +def inspect_package(path, expected_sha): + path = Path(path) + if not re.fullmatch('[0-9a-f]{64}', expected_sha) or path.suffix != '.nupkg' or path.is_symlink(): + raise ValueError('exact nupkg and SHA256 required') + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or not 0 < info.st_size <= 64 * 1024**2: + raise ValueError('regular bounded template archive required') + with path.open('rb') as stream: + raw = stream.read(64 * 1024**2 + 1) + if len(raw) != info.st_size or hashlib.sha256(raw).hexdigest() != expected_sha: + raise ValueError('external template archive identity differs') + entries = {}; expanded = 0; seen = set() + with ZipFile(io.BytesIO(raw)) as archive: + infos = archive.infolist() + if len(infos) > 20000: + raise ValueError('template archive member bound exceeded') + for member in infos: + name = member.filename + if (not name or name.startswith('/') or '\\' in name or ':' in name or '\0' in name + or '..' in PurePosixPath(name).parts or PurePosixPath(name).as_posix() != name.rstrip('/') + or name in seen or (member.external_attr >> 16) & 0o170000 == 0o120000 + or member.file_size > 16 * 1024**2): + raise ValueError('unsafe or duplicate template archive member') + seen.add(name) + expanded += member.file_size + if expanded > 256 * 1024**2: + raise ValueError('template archive expansion bound exceeded') + if not member.is_dir(): + entries[name] = archive.read(member) + nuspecs = [name for name in entries if name.endswith('.nuspec')] + if len(nuspecs) != 1 or len(entries[nuspecs[0]]) > 1024**2 or b' 64 * 1024**2: + raise ValueError('retained Rendering archive absent or unbounded') + with path.open('rb') as stream: + raw = stream.read(64 * 1024**2 + 1) + if len(raw) > 64 * 1024**2 or hashlib.sha256(raw).hexdigest() != expected_sha: + raise ValueError('Rendering archive differs from exact original candidate') + rows.append({'name': path.name, 'sha256': expected_sha}) + if identity == 'FS.GG.UI.Scene.SvgBrowser': + browser_raw = raw + with ZipFile(io.BytesIO(browser_raw)) as archive: + for name, expected_sha in RENDERING_FABLE_ENTRIES.items(): + if hashlib.sha256(archive.read(name)).hexdigest() != expected_sha: + raise ValueError('original packaged Fable entry differs') + return {'schema': 'fsgg.external-reference-retained-rendering-package-input/1', + 'producerRevision': source, 'producerTree': None, 'version': version, 'archives': rows, + 'fableEntryHashes': RENDERING_FABLE_ENTRIES, + 'identitySource': 'exact-original-archive-and-Fable-entry-hashes', + 'originalOuterArtifact': 11725317255, + 'originalOuterSha256': '7b0532caf431cfa41acaf35555016cf126bc495c5a6e13fe7db6b8fbe7478cca', + 'custodyManifestSha256': '85bb1dcdf0378d05ca536d5878b88dedc301ff1524d39d98966e23888aae0120', + 'feedQualification': 'not-established', 'installedAcceptance': False} + + +def validate_generated(workspace): + workspace = Path(workspace).resolve() + spec = importlib.util.spec_from_file_location('maintained_public_references', Path(__file__).with_name('external-reference-public.py')) + references = importlib.util.module_from_spec(spec); spec.loader.exec_module(references) + projects = sorted([*workspace.rglob('*.fsproj'), *workspace.rglob('*.csproj'), *workspace.rglob('*.vbproj')]) + build_inputs = sorted([*projects, *workspace.rglob('*.props'), *workspace.rglob('*.targets')]) + if not projects or len(build_inputs) > 1000: + raise ValueError('bounded generated project closure required') + for project in build_inputs: + if project.is_symlink() or not project.resolve().is_relative_to(workspace) or project.stat().st_size > 1024**2: + raise ValueError('generated project escaped workspace') + tree = ET.parse(project) + # MSBuild's standard XML namespace has the same literal reference rules. + for node in tree.iter(): + if node.tag.startswith('{'): + namespace, local = node.tag[1:].split('}', 1) + if namespace != 'http://schemas.microsoft.com/developer/msbuild/2003': + raise ValueError('unsupported generated build XML namespace') + node.tag = local + relative = project.relative_to(workspace).as_posix() + if relative in references.LOCAL_PROJECT_REFERENCES: + references.validate_project_references(tree, relative, workspace) + else: + for node in tree.findall('.//ProjectReference'): + value = node.attrib.get('Include', '') + if set(node.attrib) != {'Include'} or list(node) or not value or any(v in value for v in ['$', '*', '?']): + raise ValueError('unresolved generated project reference') + target = project.parent / value.replace('\\', '/') + if target.is_symlink() or not target.resolve().is_relative_to(workspace) or not target.is_file(): + raise ValueError('generated project reference outside installed workspace') + # Producer source must not be imported or compiled through a second path either. + for node in tree.iter(): + if node.tag not in ['Compile', 'Import']: + continue + value = node.attrib.get('Include' if node.tag == 'Compile' else 'Project', '') + if not value: + continue + if '$(' in value or '*' in value or '?' in value: + raise ValueError('unresolved generated source/import path') + target = (project.parent / value.replace('\\', '/')).resolve() + if not target.is_relative_to(workspace): + raise ValueError('generated source/import outside installed workspace') + return {'schema': 'fsgg.external-reference-generated-package-workspace/1', 'projects': len(projects), + 'buildInputs': len(build_inputs), 'scope': 'literal project/source/import references in generated projects, props and targets', + 'producerSourceReferenced': False, 'installedAcceptance': False} + + +if __name__ == '__main__': + if len(sys.argv) == 5 and sys.argv[1] == 'inspect': + result = inspect_package(Path(sys.argv[2]), sys.argv[3]); output = Path(sys.argv[4]) + elif len(sys.argv) == 6 and sys.argv[1] == 'rendering': + result = verify_rendering_feed(Path(sys.argv[2]), sys.argv[3], sys.argv[4]); output = Path(sys.argv[5]) + elif len(sys.argv) == 4 and sys.argv[1] == 'generated': + result = validate_generated(Path(sys.argv[2])); output = Path(sys.argv[3]) + else: + raise SystemExit('usage: external-reference-template-package.py inspect NUPKG SHA FRESH_JSON | generated WORKSPACE FRESH_JSON') + with output.open('x', encoding='utf8') as stream: + json.dump(result, stream, indent=2); stream.write('\n') diff --git a/tests/composition/fable-game/test-external-reference-template-package.py b/tests/composition/fable-game/test-external-reference-template-package.py new file mode 100644 index 00000000..1172b06f --- /dev/null +++ b/tests/composition/fable-game/test-external-reference-template-package.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Offline package/preflight controls; fake dotnet stops before any generated workload.""" +from pathlib import Path +import hashlib +import importlib.util +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch +from zipfile import ZipFile + +ROOT = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location('package_input', ROOT/'external-reference-template-package.py') +module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) +SHA = '9b73395658d4683a067d150ca9441b04e802c4af' + + +def package(path, version='0.18.3', identity=module.PACKAGE_ID, repository='https://github.com/FS-GG/FS.GG.Templates', extra=None): + nuspec=f'{identity}{version}' + with ZipFile(path, 'w') as archive: + archive.writestr('template.nuspec', nuspec) + archive.writestr('content/templates/fs-gg-fable-game/.template.config/template.json', json.dumps({'identity':'FS.GG.Workspace.Template.FableGame','groupIdentity':'FS.GG.Workspace.Template.FableGame','shortName':'fs-gg-fable-game'})) + if extra: archive.writestr(extra, 'unexpected') + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class PackageControls(unittest.TestCase): + def test_closed_identity_and_truthful_scope(self): + with tempfile.TemporaryDirectory() as tmp: + path=Path(tmp)/'input.nupkg'; sha=package(path); result=module.inspect_package(path,sha) + self.assertEqual(result['producerRevision'], SHA) + self.assertEqual(result['version'],'0.18.3') + self.assertEqual(result['feedQualification'],'not-established') + self.assertIs(result['installedAcceptance'],False) + + def test_bad_identity_and_archive_blocked(self): + for values in [{'version':'0.18.2'}, {'identity':'FS.GG.Other'}, {'repository':'https://wrong.example/repo'}, + {'extra':'../escaped'}, {'extra':'/absolute'}, {'extra':'content//alias'}]: + with self.subTest(values=values), tempfile.TemporaryDirectory() as tmp: + path=Path(tmp)/'input.nupkg'; sha=package(path,**values) + with self.assertRaises(ValueError): module.inspect_package(path,sha) + + def test_hash_and_link_blocked(self): + with tempfile.TemporaryDirectory() as tmp: + path=Path(tmp)/'input.nupkg'; sha=package(path) + for bad in ['0'*64,sha.upper(),'']: + with self.subTest(bad=bad),self.assertRaises(ValueError):module.inspect_package(path,bad) + link=Path(tmp)/'link.nupkg';link.symlink_to(path) + with self.assertRaises(ValueError):module.inspect_package(link,sha) + + def test_generated_closure_without_producer_source(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);(root/'A').mkdir();(root/'B').mkdir();(root/'B/B.fsproj').write_text('') + project=root/'A/A.fsproj'; project.write_text('') + (root/'A/Main.fs').write_text('module Main') + result=module.validate_generated(root) + self.assertIs(result['producerSourceReferenced'],False) + for bad in ['../../producer.fs','$(ProducerRoot)/source.fs']: + project.write_text(f'') + with self.subTest(bad=bad),self.assertRaises(ValueError):module.validate_generated(root) + project.write_text('') + with self.assertRaises(ValueError):module.validate_generated(root) + project.write_text('') + for name in ['Directory.Build.props', 'Generated.targets']: + imported = root / name + imported.write_text('') + with self.subTest(build_input=name), self.assertRaises(ValueError):module.validate_generated(root) + imported.unlink() + namespace='http://schemas.microsoft.com/developer/msbuild/2003' + for element in ['', + '', + '']: + project.write_text(f'{element}') + with self.subTest(namespace_element=element),self.assertRaises(ValueError):module.validate_generated(root) + project.write_text(f'') + self.assertFalse(module.validate_generated(root)['producerSourceReferenced']) + project.write_text('') + with self.assertRaises(ValueError):module.validate_generated(root) + + def test_rendering_hash_correspondence_and_refusals(self): + with tempfile.TemporaryDirectory() as tmp: + feed=Path(tmp) + files={name:('fixture '+name).encode() for name in module.RENDERING_FABLE_ENTRIES} + pins={} + for identity in module.RENDERING_ARCHIVES: + archive=feed/(identity+'.0.32.2.nupkg') + with ZipFile(archive,'w') as z: + if identity.endswith('SvgBrowser'): + for name,raw in files.items():z.writestr(name,raw) + else:z.writestr('fixture.txt','not original native bytes') + pins[identity]=hashlib.sha256(archive.read_bytes()).hexdigest() + entries={name:hashlib.sha256(raw).hexdigest()for name,raw in files.items()} + with patch.object(module,'RENDERING_ARCHIVES',pins),patch.object(module,'RENDERING_FABLE_ENTRIES',entries): + sizes=[];real_open=Path.open + class BoundedReader: + def __init__(self,stream):self.stream=stream + def __enter__(self):self.stream.__enter__();return self + def __exit__(self,*args):return self.stream.__exit__(*args) + def read(self,size=-1):sizes.append(size);return self.stream.read(size) + with patch.object(Path,'open',lambda path,*args,**kwargs:BoundedReader(real_open(path,*args,**kwargs))): + value=module.verify_rendering_feed(feed,'0.32.2',SHA) + self.assertEqual(sizes,[64*1024**2+1]*3) + self.assertIs(value['installedAcceptance'],False) + self.assertEqual(value['feedQualification'],'not-established') + self.assertIsNone(value['producerTree']) + for version,source in [('0.32.1',SHA),('0.32.2','0'*40)]: + with self.subTest(version=version,source=source),self.assertRaises(ValueError): + module.verify_rendering_feed(feed,version,source) + with patch.object(module,'RENDERING_FABLE_ENTRIES',{**entries,next(iter(entries)):'0'*64}): + with self.assertRaises(ValueError):module.verify_rendering_feed(feed,'0.32.2',SHA) + path=feed/'FS.GG.UI.KeyboardInput.0.32.2.nupkg';path.write_bytes(path.read_bytes()+b'changed') + with self.assertRaises(ValueError):module.verify_rendering_feed(feed,'0.32.2',SHA) + + def test_existing_runner_external_branch_has_no_producer_source(self): + with tempfile.TemporaryDirectory() as tmp: + base=Path(tmp);controller=base/'controller/tests/composition/fable-game';controller.mkdir(parents=True) + for name in ['verify-external-reference-candidate.sh','external-reference-template-package.py']: + shutil.copyfile(ROOT/name,controller/name) + producer=base/'absent-rendering' + feed=base/'feed';feed.mkdir();files=['SvgExternalSessionHost.fs','SvgExternalSessionHost.fsi','SvgInputHost.fs','SvgInputHost.fsi'] + for identity in ['FS.GG.UI.Scene','FS.GG.UI.KeyboardInput','FS.GG.UI.Scene.SvgBrowser']: + with ZipFile(feed/(identity+'.0.32.2.nupkg'),'w') as archive: + if identity.endswith('SvgBrowser'): + for name in files:archive.writestr('fable/'+name,name) + archive.writestr('fable-compatibility/compatibility-profile.v1.json','{}') + else:archive.writestr('fixture.txt','not native evidence') + # The actual shell route uses a private inert feed-pin seam, never production pins. + fixture_archives={identity:hashlib.sha256((feed/(identity+'.0.32.2.nupkg')).read_bytes()).hexdigest() + for identity in module.RENDERING_ARCHIVES} + fixture_entries={'fable/'+name:hashlib.sha256(name.encode()).hexdigest() for name in files} + fixture_entries['fable-compatibility/compatibility-profile.v1.json']=hashlib.sha256(b'{}').hexdigest() + copied=controller/'external-reference-template-package.py' + copied.write_text(copied.read_text().replace(repr(module.RENDERING_ARCHIVES),repr(fixture_archives)) + .replace(repr(module.RENDERING_FABLE_ENTRIES),repr(fixture_entries))) + archive=base/'external.nupkg';sha=package(archive);tools=base/'tools';tools.mkdir();calls=base/'dotnet-calls.jsonl' + (tools/'git').write_text('#!/usr/bin/env python3\nraise SystemExit("producer source read forbidden in external package fixture")\n') + (tools/'dotnet').write_text('#!/usr/bin/env python3\nimport sys,os,json\nwith open(os.environ["CALLS"],"a")as f:f.write(json.dumps({"args":sys.argv[1:],"home":os.environ["DOTNET_CLI_HOME"],"cache":os.environ["NUGET_PACKAGES"]})+"\\n")\nassert sys.argv[1:3] in [["new","install"],["new","fs-gg-fable-game"]]\nsys.exit(0 if sys.argv[2]=="install" else 9)\n') + for path in tools.iterdir():path.chmod(0o700) + env={**os.environ,'PATH':str(tools)+os.pathsep+os.environ['PATH'],'CALLS':str(calls)} + out=base/'qualification';argv=['bash',str(controller/'verify-external-reference-candidate.sh'),str(producer),str(feed),'0.32.2',str(out),SHA,'candidate',str(archive),sha] + result=subprocess.run(argv,env=env,capture_output=True,text=True,timeout=10) + self.assertEqual(result.returncode,9,result.stderr) + rows=[json.loads(line)for line in calls.read_text().splitlines()];self.assertEqual(len(rows),2) + self.assertIn('--debug:custom-hive',rows[0]['args']);self.assertIn(str(out/'template-hive'),rows[0]['args']) + self.assertIn('--package',rows[1]['args']);self.assertEqual(rows[0]['home'],str(out/'home'));self.assertEqual(rows[0]['cache'],str(out/'packages')) + self.assertFalse((base/'controller/FS.GG.Templates.csproj').exists());self.assertFalse((base/'controller/templates').exists()) + self.assertFalse(producer.exists()) + bad_out=base/'bad' + bad=['bash',str(controller/'verify-external-reference-candidate.sh'),str(producer),str(feed),'0.32.2',str(bad_out),SHA,'candidate',str(archive),'0'*64] + result=subprocess.run(bad,env=env,capture_output=True,text=True,timeout=10) + self.assertNotEqual(result.returncode,0);self.assertEqual(len(calls.read_text().splitlines()),2) + + +if __name__=='__main__':unittest.main() diff --git a/tests/composition/fable-game/verify-external-reference-candidate.sh b/tests/composition/fable-game/verify-external-reference-candidate.sh index f07d2aab..36040338 100755 --- a/tests/composition/fable-game/verify-external-reference-candidate.sh +++ b/tests/composition/fable-game/verify-external-reference-candidate.sh @@ -1,20 +1,27 @@ #!/usr/bin/env bash set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" -producer="${1:?exact Rendering source checkout required}" +producer="${1:?Rendering source checkout or absent external-package input root required}" producer_feed="${2:?packed Rendering source feed required}" version="${3:?isolated Rendering candidate version required}" out="${4:?empty qualification directory required}" expected_source="${5:-097e228388375bf27a03ac008627e4a038686c53}" input_mode="${6:-candidate}" +templates_package="${7:-}" +templates_package_sha="${8:-}" +[[ "$#" -le 8 ]] +[[ -z "$templates_package" && -z "$templates_package_sha" || -n "$templates_package" && "$templates_package_sha" =~ ^[0-9a-f]{64}$ ]] || { echo "external template package requires exact path and SHA256" >&2; exit 1; } [[ "$input_mode" == candidate || "$input_mode" == public ]] || { echo "unknown input mode" >&2; exit 1; } if [[ "$input_mode" == public ]]; then [[ "$version" == 0.32.1 && "$expected_source" == 6c9f766fdd91483c2de6f061e75589e94852a265 ]] [[ -z "${FsGgSvgInputVersion:-}" && -z "${FsGgSvgAuthoringVersion:-}" ]] || { echo "public defaults cannot be overridden" >&2; exit 1; } fi [[ "$expected_source" =~ ^[0-9a-f]{40}$ && "$version" =~ ^[0-9A-Za-z][0-9A-Za-z.+-]*$ && "$version" != 0.31.0 ]] -[[ ! -e "$out" && "$(git -C "$producer" rev-parse HEAD)" == "$expected_source" ]] -[[ -z "$(git -C "$producer" status --porcelain --untracked-files=no)" ]] +[[ ! -e "$out" ]] +if [[ -z "$templates_package" ]]; then + [[ "$(git -C "$producer" rev-parse HEAD)" == "$expected_source" ]] + [[ -z "$(git -C "$producer" status --porcelain --untracked-files=no)" ]] +fi mkdir -p "$out"/{feed,home,packages,http} out="$(realpath "$out")" export DOTNET_CLI_HOME="$out/home" NUGET_PACKAGES="$out/packages" NUGET_HTTP_CACHE_PATH="$out/http" @@ -28,11 +35,14 @@ else cp "$producer_feed/$id.$version.nupkg" "$out/feed/" done fi -python3 - "$producer" "$out/feed" "$version" <<'PY' +if [[ -n "$templates_package" ]]; then + python3 "$root/tests/composition/fable-game/external-reference-template-package.py" rendering "$out/feed" "$version" "$expected_source" "$out/producer-package-input.json" +fi +python3 - "$producer" "$out/feed" "$version" "$templates_package" <<'PY' from pathlib import Path from zipfile import ZipFile import hashlib,json,sys -source,feed=map(Path,sys.argv[1:3]); version=sys.argv[3] +source,feed=map(Path,sys.argv[1:3]); version=sys.argv[3]; external=bool(sys.argv[4]) rows=[] source_entries=[] for package in sorted(feed.glob('*.nupkg')): @@ -40,10 +50,10 @@ for package in sorted(feed.glob('*.nupkg')): with ZipFile(feed/f'FS.GG.UI.Scene.SvgBrowser.{version}.nupkg') as archive: for name in ('SvgExternalSessionHost.fs','SvgExternalSessionHost.fsi','SvgInputHost.fs','SvgInputHost.fsi'): raw=archive.read('fable/'+name) - assert raw==(source/'src/Scene.SvgBrowser'/name).read_bytes(), name + if not external: assert raw==(source/'src/Scene.SvgBrowser'/name).read_bytes(), name source_entries.append({'path':'fable/'+name,'sha256':hashlib.sha256(raw).hexdigest()}) profile='fable-compatibility/compatibility-profile.v1.json' - assert archive.read(profile)==(source/'src/Scene.SvgBrowser/Fable/compatibility-profile.v1.json').read_bytes() + if not external: assert archive.read(profile)==(source/'src/Scene.SvgBrowser/Fable/compatibility-profile.v1.json').read_bytes() source_entries.append({'path':profile,'sha256':hashlib.sha256(archive.read(profile)).hexdigest()}) (feed.parent/'producer-archives.json').write_text(json.dumps(rows,indent=2)+'\n') (feed.parent/'producer-source-entries.json').write_text(json.dumps(source_entries,indent=2)+'\n') @@ -55,15 +65,40 @@ cat > "$out/NuGet.Config" < CONFIG fi -# Reuse the canonical checked Python projection for a fresh complete Templates archive. -. "$root/tests/composition/fable-game/svg-source-python-fixture.sh" -prepare_svg_source_python_fixture "$root" "$out" -dotnet pack "$root/FS.GG.Templates.csproj" -c Release -o "$out/template" -m:1 -p:UseSharedCompilation=false >"$out/templates-pack.log" 2>&1 -mapfile -t archives < <(find "$out/template" -maxdepth 1 -name '*.nupkg' -type f) -[[ "${#archives[@]}" == 1 ]] -dotnet new install "${archives[0]}" --force >"$out/template-install.log" 2>&1 -dotnet new fs-gg-fable-game -n ExternalReference -o "$out/complete" --bundle complete --lifecycle none >"$out/complete-generate.log" 2>&1 -dotnet new fs-gg-fable-game -n DefaultPlayer -o "$out/player" >"$out/player-generate.log" 2>&1 +template_scope_args=() +if [[ -n "$templates_package" ]]; then + # An external archive is a distinct candidate input; no producer pack/projection/source lookup. + # Keep generated builds outside either producer's automatic MSBuild source hierarchy. + python3 - "$out" "$root" "$producer" <<'PYBOUNDARY' +from pathlib import Path +import sys +output,controller,rendering=map(lambda value:Path(value).resolve(),sys.argv[1:]) +assert not output.is_relative_to(controller) and not output.is_relative_to(rendering), 'external package output must be outside producer roots' +PYBOUNDARY + python3 "$root/tests/composition/fable-game/external-reference-template-package.py" inspect \ + "$templates_package" "$templates_package_sha" "$out/template-package-input.json" + mkdir "$out/template" "$out/template-hive" + cp "$templates_package" "$out/template/FS.GG.Workspace.Template.0.18.3.nupkg" + archive="$out/template/FS.GG.Workspace.Template.0.18.3.nupkg" + printf '%s %s\n' "$templates_package_sha" "$archive" | sha256sum --check --strict + dotnet new install "$archive" --force --debug:custom-hive "$out/template-hive" >"$out/template-install.log" 2>&1 + template_scope_args=(--debug:custom-hive "$out/template-hive" --debug:disable-sdk-templates --no-update-check --package FS.GG.Workspace.Template) +else + # Preserve the existing source-built candidate and its canonical checked Python projection. + . "$root/tests/composition/fable-game/svg-source-python-fixture.sh" + prepare_svg_source_python_fixture "$root" "$out" + dotnet pack "$root/FS.GG.Templates.csproj" -c Release -o "$out/template" -m:1 -p:UseSharedCompilation=false >"$out/templates-pack.log" 2>&1 + mapfile -t archives < <(find "$out/template" -maxdepth 1 -name '*.nupkg' -type f) + [[ "${#archives[@]}" == 1 ]] + dotnet new install "${archives[0]}" --force >"$out/template-install.log" 2>&1 +fi +dotnet new fs-gg-fable-game -n ExternalReference -o "$out/complete" --bundle complete --lifecycle none "${template_scope_args[@]}" >"$out/complete-generate.log" 2>&1 +dotnet new fs-gg-fable-game -n DefaultPlayer -o "$out/player" "${template_scope_args[@]}" >"$out/player-generate.log" 2>&1 +if [[ -n "$templates_package" ]]; then + python3 "$root/tests/composition/fable-game/external-reference-template-package.py" generated \ + "$out/complete" "$out/template-generated-independent.json" + printf '%s %s\n' "$templates_package_sha" "$archive" | sha256sum --check --strict +fi [[ -f "$out/complete/SvgFoundation/Examples/ExternalAuthority/reference.json" ]] [[ -f "$out/complete/SvgFoundation/Examples/FourD/reference.json" ]] [[ ! -e "$out/player/SvgFoundation/Examples/ExternalAuthority" && ! -e "$out/player/SvgFoundation/Examples/FourD" ]] @@ -148,16 +183,31 @@ report=json.load(open(sys.argv[1])); stats=report['stats'] assert stats['expected']==8 and stats['unexpected']==0 and stats['skipped']==0 and stats['flaky']==0, stats PY done -python3 - "$root" "$producer" "$out" "$version" "$producer_feed" "$input_mode" <<'PY' +python3 - "$root" "$producer" "$out" "$version" "$producer_feed" "$input_mode" "$templates_package" <<'PY' from pathlib import Path import hashlib,json,subprocess,sys root,producer,out=map(Path,sys.argv[1:4]); version=sys.argv[4] def git(path,field): return subprocess.check_output(['git','-C',str(path),'rev-parse',field],text=True).strip() def hashes(path): return [{'path':p.relative_to(path).as_posix(),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()} for p in sorted(path.rglob('*')) if p.is_file() and 'node_modules' not in p.parts and 'obj' not in p.parts and 'bin' not in p.parts] -packet={'schema':'fsgg.external-reference-source-qualification/1','disposition':'passed','templates':{'revision':git(root,'HEAD'),'tree':git(root,'HEAD^{tree}')},'producer':{'inputCustody':json.loads((Path(sys.argv[5])/'ROOT-CUSTODY.json').read_text()) if (Path(sys.argv[5])/'ROOT-CUSTODY.json').exists() else None,'custodyManifestSha256':hashlib.sha256((Path(sys.argv[5])/'release-custody.json').read_bytes()).hexdigest() if (Path(sys.argv[5])/'release-custody.json').exists() else None,'revision':git(producer,'HEAD'),'tree':git(producer,'HEAD^{tree}'),'candidateVersion':version,'archives':json.loads((out/'producer-archives.json').read_text()),'sourceEntries':json.loads((out/'producer-source-entries.json').read_text())},'environment':{'dotnet':subprocess.check_output(['dotnet','--info'],text=True),'node':subprocess.check_output(['node','--version'],text=True).strip(),'npm':subprocess.check_output(['npm','--version'],text=True).strip(),'fableLoadedVersion':(out/'fable-version.log').read_text().strip(),'fableLogSha256':hashlib.sha256((out/'fable-build.log').read_bytes()).hexdigest()},'receiver':hashes(out/'complete/SvgFoundation'),'browserFamilies':['chromium','firefox','webkit'],'browserReports':hashes(out/'complete/Browser.Tests/test-results'),'browserReportSha256':{family:hashlib.sha256((out/(family+'-browser.json')).read_bytes()).hexdigest() for family in ['chromium','firefox','webkit']},'passedPerFamily':8,'skipped':0,'domAutomation':True,'actualScreenReader':'not-observed','publication':False,'installedAcceptance':False,'renderingInputSource':'nuget.org' if sys.argv[6]=='public' else 'candidate','renderingPublicInputsQualified':sys.argv[6]=='public','templatesInputSource':'source-built-candidate','publicReadback':json.loads((out/'feed/public-readback.json').read_text()) if sys.argv[6]=='public' else None} +template_input=json.loads((out/'template-package-input.json').read_text()) if sys.argv[7] else None +rendering_input=json.loads((out/'producer-package-input.json').read_text()) if template_input else None +producer_identity={'revision':rendering_input['producerRevision'],'tree':None} if rendering_input else {'revision':git(producer,'HEAD'),'tree':git(producer,'HEAD^{tree}')} +template_identity={'revision':template_input['producerRevision'],'tree':None} if template_input else {'revision':git(root,'HEAD'),'tree':git(root,'HEAD^{tree}')} +packet={'schema':'fsgg.external-reference-source-qualification/1','disposition':'passed','templates':template_identity,'producer':{'inputCustody':json.loads((Path(sys.argv[5])/'ROOT-CUSTODY.json').read_text()) if (Path(sys.argv[5])/'ROOT-CUSTODY.json').exists() else None,'custodyManifestSha256':hashlib.sha256((Path(sys.argv[5])/'release-custody.json').read_bytes()).hexdigest() if (Path(sys.argv[5])/'release-custody.json').exists() else None,**producer_identity,'candidateVersion':version,'archives':json.loads((out/'producer-archives.json').read_text()),'sourceEntries':json.loads((out/'producer-source-entries.json').read_text())},'environment':{'dotnet':subprocess.check_output(['dotnet','--info'],text=True),'node':subprocess.check_output(['node','--version'],text=True).strip(),'npm':subprocess.check_output(['npm','--version'],text=True).strip(),'fableLoadedVersion':(out/'fable-version.log').read_text().strip(),'fableLogSha256':hashlib.sha256((out/'fable-build.log').read_bytes()).hexdigest()},'receiver':hashes(out/'complete/SvgFoundation'),'browserFamilies':['chromium','firefox','webkit'],'browserReports':hashes(out/'complete/Browser.Tests/test-results'),'browserReportSha256':{family:hashlib.sha256((out/(family+'-browser.json')).read_bytes()).hexdigest() for family in ['chromium','firefox','webkit']},'passedPerFamily':8,'skipped':0,'domAutomation':True,'actualScreenReader':'not-observed','publication':False,'installedAcceptance':False,'renderingInputSource':'nuget.org' if sys.argv[6]=='public' else 'candidate','renderingPublicInputsQualified':sys.argv[6]=='public','templatesInputSource':'source-built-candidate','publicReadback':json.loads((out/'feed/public-readback.json').read_text()) if sys.argv[6]=='public' else None} if sys.argv[6]=='public': packet['schema']='fsgg.external-reference-public-input-qualification/1' packet['producer']['publishedVersion']=packet['producer'].pop('candidateVersion') +if template_input: + packet['schema']='fsgg.external-reference-template-package-qualification/1' + packet['templatesInputSource']='external-package-candidate' + packet['templatePackageInput']=template_input + packet['renderingPackageInput']=rendering_input + packet['renderingProducerSourceUnavailable']=not (producer/'.git').exists() and not (producer/'src').exists() + packet['renderingSourceCorrespondence']='reused exact original packaged-entry hashes; no producer tree read in this mode' + packet['templateGeneratedIndependent']=json.loads((out/'template-generated-independent.json').read_text()) + packet['templateProducerSourceUnavailable']=not (root/'FS.GG.Templates.csproj').exists() and not (root/'templates').exists() + # Local immutable package installation does not establish released-feed installed adoption. + assert packet['installedAcceptance'] is False and template_input['installedAcceptance'] is False (out/'qualification.json').write_text(json.dumps(packet,indent=2)+'\n') PY printf 'external-reference-candidate: passed; evidence=%s/qualification.json\n' "$out"