build: verify exact packaged addon contents
This commit is contained in:
parent
bf53e4f2fc
commit
644c99a9ca
9 changed files with 122 additions and 35 deletions
|
|
@ -17,6 +17,7 @@ The repository now provides:
|
||||||
- an on-demand source/build entry point that never installs the Windows
|
- an on-demand source/build entry point that never installs the Windows
|
||||||
toolchain;
|
toolchain;
|
||||||
- a non-executing verifier for externally produced Windows artifacts;
|
- a non-executing verifier for externally produced Windows artifacts;
|
||||||
|
it requires an exact repository-to-manifest-to-ZIP addon file set;
|
||||||
- a checksummed acceptance-kit generator containing only allowlisted public
|
- a checksummed acceptance-kit generator containing only allowlisted public
|
||||||
synthetic inputs, with provenance binding to the repository commit and
|
synthetic inputs, with provenance binding to the repository commit and
|
||||||
non-executing artifact-verification report, including the Rayforge pin and
|
non-executing artifact-verification report, including the Rayforge pin and
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,10 @@ python scripts/verify_rayforge_windows_checkpoint.py `
|
||||||
|
|
||||||
The verifier requires checkpoint schema 2 and portable artifact names, checks
|
The verifier requires checkpoint schema 2 and portable artifact names, checks
|
||||||
sizes and SHA-256 digests, rejects unsafe ZIP paths, confirms the pinned
|
sizes and SHA-256 digests, rejects unsafe ZIP paths, confirms the pinned
|
||||||
Rayforge commit, compares every packaged addon file with its recorded digest,
|
Rayforge commit, requires the manifest and bundle to contain exactly the
|
||||||
and requires the pinned Rayforge license both inside the ZIP and as a sidecar.
|
versioned repository addon file set with matching digests, and requires the
|
||||||
|
pinned Rayforge license both inside the ZIP and as a sidecar. Missing, changed
|
||||||
|
or additional addon files fail verification.
|
||||||
It never executes the application or installer, and a passing result retains
|
It never executes the application or installer, and a passing result retains
|
||||||
the manual GUI interaction gate. The installer, manifest and license sidecar
|
the manual GUI interaction gate. The installer, manifest and license sidecar
|
||||||
form one distribution set.
|
form one distribution set.
|
||||||
|
|
@ -99,7 +101,8 @@ generated manual report to the kit manifest, repository commit and copied
|
||||||
artifact-verification report by SHA-256. Editing, replacing or referring to an
|
artifact-verification report by SHA-256. Editing, replacing or referring to an
|
||||||
external provenance report therefore fails validation. The copied report must
|
external provenance report therefore fails validation. The copied report must
|
||||||
also name the pinned Rayforge commit, contain valid bundle and installer
|
also name the pinned Rayforge commit, contain valid bundle and installer
|
||||||
SHA-256 values, and confirm that at least one packaged addon file was verified.
|
SHA-256 values, and confirm that the exact current addon file count was
|
||||||
|
verified.
|
||||||
|
|
||||||
1. Pass the non-executing artifact verifier above.
|
1. Pass the non-executing artifact verifier above.
|
||||||
2. Launch the portable build with a fresh application-data directory.
|
2. Launch the portable build with a fresh application-data directory.
|
||||||
|
|
|
||||||
|
|
@ -324,7 +324,9 @@ reports. The on-demand build entry point fetches pinned source and stages the
|
||||||
addon but deliberately refuses to install a missing Windows toolchain.
|
addon but deliberately refuses to install a missing Windows toolchain.
|
||||||
An artifact produced elsewhere can be checked locally without execution: the
|
An artifact produced elsewhere can be checked locally without execution: the
|
||||||
verifier rejects unsafe paths or changed bytes and confirms the pin plus every
|
verifier rejects unsafe paths or changed bytes and confirms the pin plus every
|
||||||
packaged addon source before the manual GUI checklist may begin.
|
packaged addon source before the manual GUI checklist may begin. It also
|
||||||
|
rejects omitted manifest entries and extra files hidden in the packaged addon
|
||||||
|
directory.
|
||||||
The manual checklist now also has a versioned, fail-closed report template and
|
The manual checklist now also has a versioned, fail-closed report template and
|
||||||
validator. Missing steps remain incomplete, stop conditions fail the run, and
|
validator. Missing steps remain incomplete, stop conditions fail the run, and
|
||||||
hashed screenshots stay outside the public repository pending sanitization.
|
hashed screenshots stay outside the public repository pending sanitization.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ RAYFORGE_COMMIT = json.loads(
|
||||||
(ROOT / "upstreams" / "rayforge.json").read_text(encoding="utf-8")
|
(ROOT / "upstreams" / "rayforge.json").read_text(encoding="utf-8")
|
||||||
)["commit"]
|
)["commit"]
|
||||||
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
EXPECTED_ADDON_FILES = sum(
|
||||||
|
path.is_file() and "__pycache__" not in path.parts
|
||||||
|
for path in (ROOT / "spikes" / "rayforge-jcz-no-device-addon").rglob("*")
|
||||||
|
)
|
||||||
REPORT_TEMPLATE = "tests/fixtures/m1-manual-acceptance-template.json"
|
REPORT_TEMPLATE = "tests/fixtures/m1-manual-acceptance-template.json"
|
||||||
KIT_FILES = {
|
KIT_FILES = {
|
||||||
"inputs/synthetic-mopa-import.lbrn2": "tests/fixtures/synthetic-mopa-import.lbrn2",
|
"inputs/synthetic-mopa-import.lbrn2": "tests/fixtures/synthetic-mopa-import.lbrn2",
|
||||||
|
|
@ -60,8 +64,7 @@ def create_kit(
|
||||||
or verification.get("rayforge_commit") != RAYFORGE_COMMIT
|
or verification.get("rayforge_commit") != RAYFORGE_COMMIT
|
||||||
or SHA256.fullmatch(verification.get("bundle_sha256", "")) is None
|
or SHA256.fullmatch(verification.get("bundle_sha256", "")) is None
|
||||||
or SHA256.fullmatch(verification.get("installer_sha256", "")) is None
|
or SHA256.fullmatch(verification.get("installer_sha256", "")) is None
|
||||||
or not isinstance(verification.get("verified_addon_files"), int)
|
or verification.get("verified_addon_files") != EXPECTED_ADDON_FILES
|
||||||
or verification.get("verified_addon_files", 0) <= 0
|
|
||||||
):
|
):
|
||||||
raise ValueError("artifact-verification report did not pass the no-device gate")
|
raise ValueError("artifact-verification report did not pass the no-device gate")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ STOP_CONDITIONS = (
|
||||||
STEP_STATUSES = {"passed", "failed", "blocked", "not_run"}
|
STEP_STATUSES = {"passed", "failed", "blocked", "not_run"}
|
||||||
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||||
GIT_COMMIT = re.compile(r"^[0-9a-f]{40}$")
|
GIT_COMMIT = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
EXPECTED_ADDON_FILES = sum(
|
||||||
|
path.is_file() and "__pycache__" not in path.parts
|
||||||
|
for path in (ROOT / "spikes" / "rayforge-jcz-no-device-addon").rglob("*")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_evidence(report_root: Path, records: list[dict]) -> list[str]:
|
def _validate_evidence(report_root: Path, records: list[dict]) -> list[str]:
|
||||||
|
|
@ -120,11 +124,8 @@ def _validate_artifact_report(report_root: Path, binding: dict) -> list[str]:
|
||||||
errors.append("artifact verification has an invalid bundle SHA-256")
|
errors.append("artifact verification has an invalid bundle SHA-256")
|
||||||
if SHA256.fullmatch(verification.get("installer_sha256", "")) is None:
|
if SHA256.fullmatch(verification.get("installer_sha256", "")) is None:
|
||||||
errors.append("artifact verification has an invalid installer SHA-256")
|
errors.append("artifact verification has an invalid installer SHA-256")
|
||||||
if (
|
if verification.get("verified_addon_files") != EXPECTED_ADDON_FILES:
|
||||||
not isinstance(verification.get("verified_addon_files"), int)
|
errors.append("artifact verification did not verify the exact addon file set")
|
||||||
or verification.get("verified_addon_files", 0) <= 0
|
|
||||||
):
|
|
||||||
errors.append("artifact verification did not verify addon files")
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ADDON_SOURCE = ROOT / "spikes" / "rayforge-jcz-no-device-addon"
|
||||||
UPSTREAM = json.loads(
|
UPSTREAM = json.loads(
|
||||||
(ROOT / "upstreams" / "rayforge.json").read_text(encoding="utf-8")
|
(ROOT / "upstreams" / "rayforge.json").read_text(encoding="utf-8")
|
||||||
)
|
)
|
||||||
|
|
@ -44,6 +45,24 @@ def _check_file(root: Path, record: dict, label: str) -> Path:
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_addon_files() -> dict[PurePosixPath, str]:
|
||||||
|
return {
|
||||||
|
PurePosixPath(path.relative_to(ADDON_SOURCE).as_posix()): _digest(
|
||||||
|
path.read_bytes()
|
||||||
|
)
|
||||||
|
for path in sorted(ADDON_SOURCE.rglob("*"))
|
||||||
|
if path.is_file() and "__pycache__" not in path.parts
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_below(path: PurePosixPath, root: PurePosixPath) -> PurePosixPath | None:
|
||||||
|
root_parts = root.parts
|
||||||
|
for index in range(len(path.parts) - len(root_parts)):
|
||||||
|
if path.parts[index : index + len(root_parts)] == root_parts:
|
||||||
|
return PurePosixPath(*path.parts[index + len(root_parts) :])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def verify(manifest_path: Path) -> dict:
|
def verify(manifest_path: Path) -> dict:
|
||||||
manifest_path = manifest_path.resolve()
|
manifest_path = manifest_path.resolve()
|
||||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
|
||||||
|
|
@ -69,6 +88,23 @@ def verify(manifest_path: Path) -> dict:
|
||||||
packaged_root = PurePosixPath(manifest["addon_packaged_root"])
|
packaged_root = PurePosixPath(manifest["addon_packaged_root"])
|
||||||
if packaged_root.is_absolute() or ".." in packaged_root.parts:
|
if packaged_root.is_absolute() or ".." in packaged_root.parts:
|
||||||
raise ValueError("unsafe packaged addon root")
|
raise ValueError("unsafe packaged addon root")
|
||||||
|
expected_addon = _expected_addon_files()
|
||||||
|
manifest_addon = {}
|
||||||
|
for record in manifest.get("addon_source_files", []):
|
||||||
|
relative = PurePosixPath(record.get("path", ""))
|
||||||
|
if (
|
||||||
|
not relative.parts
|
||||||
|
or relative.is_absolute()
|
||||||
|
or ".." in relative.parts
|
||||||
|
or relative in manifest_addon
|
||||||
|
):
|
||||||
|
raise ValueError(f"unsafe or duplicate addon source path: {relative}")
|
||||||
|
manifest_addon[relative] = record.get("sha256", "")
|
||||||
|
if set(manifest_addon) != set(expected_addon):
|
||||||
|
raise ValueError("checkpoint addon file list does not match the repository")
|
||||||
|
for relative, digest in manifest_addon.items():
|
||||||
|
if digest != expected_addon[relative]:
|
||||||
|
raise ValueError(f"checkpoint addon source differs from repository: {relative}")
|
||||||
|
|
||||||
with zipfile.ZipFile(bundle) as archive:
|
with zipfile.ZipFile(bundle) as archive:
|
||||||
files = {
|
files = {
|
||||||
|
|
@ -92,24 +128,19 @@ def verify(manifest_path: Path) -> dict:
|
||||||
if _text_digest_lf(archive.read(files[license_matches[0]])) != expected_license_hash:
|
if _text_digest_lf(archive.read(files[license_matches[0]])) != expected_license_hash:
|
||||||
raise ValueError("bundled Rayforge license does not match the pinned upstream")
|
raise ValueError("bundled Rayforge license does not match the pinned upstream")
|
||||||
|
|
||||||
verified_addon_files = 0
|
packaged_addon = {}
|
||||||
for record in manifest["addon_source_files"]:
|
for path in files:
|
||||||
relative = PurePosixPath(record["path"])
|
relative = _relative_below(path, packaged_root)
|
||||||
if relative.is_absolute() or ".." in relative.parts:
|
if relative is not None:
|
||||||
raise ValueError("unsafe addon source path")
|
if relative in packaged_addon:
|
||||||
matches = [
|
raise ValueError(f"packaged addon file is ambiguous: {relative}")
|
||||||
path for path in files
|
packaged_addon[relative] = path
|
||||||
if tuple(path.parts[-len((packaged_root / relative).parts):])
|
if set(packaged_addon) != set(expected_addon):
|
||||||
== (packaged_root / relative).parts
|
raise ValueError("packaged addon file set does not match the repository")
|
||||||
]
|
for relative, digest in expected_addon.items():
|
||||||
if len(matches) != 1:
|
if _digest(archive.read(files[packaged_addon[relative]])) != digest:
|
||||||
raise ValueError(f"packaged addon file missing or ambiguous: {relative}")
|
|
||||||
digest = record.get("sha256", "")
|
|
||||||
if not SHA256.fullmatch(digest):
|
|
||||||
raise ValueError(f"invalid addon SHA-256: {relative}")
|
|
||||||
if _digest(archive.read(files[matches[0]])) != digest:
|
|
||||||
raise ValueError(f"packaged addon SHA-256 mismatch: {relative}")
|
raise ValueError(f"packaged addon SHA-256 mismatch: {relative}")
|
||||||
verified_addon_files += 1
|
verified_addon_files = len(packaged_addon)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "passed",
|
"status": "passed",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from scripts.prepare_m1_acceptance_kit import (
|
from scripts.prepare_m1_acceptance_kit import (
|
||||||
KIT_FILES,
|
KIT_FILES,
|
||||||
|
EXPECTED_ADDON_FILES,
|
||||||
RAYFORGE_COMMIT,
|
RAYFORGE_COMMIT,
|
||||||
REPORT_TEMPLATE,
|
REPORT_TEMPLATE,
|
||||||
ROOT,
|
ROOT,
|
||||||
|
|
@ -24,7 +25,7 @@ class M1AcceptanceKitTests(unittest.TestCase):
|
||||||
"rayforge_commit": RAYFORGE_COMMIT,
|
"rayforge_commit": RAYFORGE_COMMIT,
|
||||||
"bundle_sha256": "1" * 64,
|
"bundle_sha256": "1" * 64,
|
||||||
"installer_sha256": "2" * 64,
|
"installer_sha256": "2" * 64,
|
||||||
"verified_addon_files": 7,
|
"verified_addon_files": EXPECTED_ADDON_FILES,
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_kit_contains_only_allowlisted_public_inputs(self):
|
def test_kit_contains_only_allowlisted_public_inputs(self):
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from scripts.validate_m1_manual_acceptance import (
|
from scripts.validate_m1_manual_acceptance import (
|
||||||
|
EXPECTED_ADDON_FILES,
|
||||||
RAYFORGE_COMMIT,
|
RAYFORGE_COMMIT,
|
||||||
ROOT,
|
ROOT,
|
||||||
STEP_IDS,
|
STEP_IDS,
|
||||||
|
|
@ -49,7 +50,7 @@ class M1ManualAcceptanceTests(unittest.TestCase):
|
||||||
"rayforge_commit": RAYFORGE_COMMIT,
|
"rayforge_commit": RAYFORGE_COMMIT,
|
||||||
"bundle_sha256": "1" * 64,
|
"bundle_sha256": "1" * 64,
|
||||||
"installer_sha256": "2" * 64,
|
"installer_sha256": "2" * 64,
|
||||||
"verified_addon_files": 7,
|
"verified_addon_files": EXPECTED_ADDON_FILES,
|
||||||
}
|
}
|
||||||
verification_path = root / "artifact-verification.json"
|
verification_path = root / "artifact-verification.json"
|
||||||
verification_path.write_text(json.dumps(verification), encoding="utf-8")
|
verification_path.write_text(json.dumps(verification), encoding="utf-8")
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,30 @@ import unittest
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from scripts.verify_rayforge_windows_checkpoint import ROOT, UPSTREAM, verify
|
from scripts.verify_rayforge_windows_checkpoint import (
|
||||||
|
ADDON_SOURCE,
|
||||||
|
ROOT,
|
||||||
|
UPSTREAM,
|
||||||
|
verify,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ADDON_ROOT = "rayforge/builtin_addons/rayforge-addon-the-pirate-ray-jcz"
|
ADDON_ROOT = "rayforge/builtin_addons/rayforge-addon-the-pirate-ray-jcz"
|
||||||
|
|
||||||
|
|
||||||
class WindowsCheckpointVerifierTests(unittest.TestCase):
|
class WindowsCheckpointVerifierTests(unittest.TestCase):
|
||||||
def _checkpoint(self, root: Path, unsafe_member: str | None = None) -> Path:
|
def _checkpoint(
|
||||||
|
self,
|
||||||
|
root: Path,
|
||||||
|
unsafe_member: str | None = None,
|
||||||
|
extra_addon_file: str | None = None,
|
||||||
|
) -> Path:
|
||||||
bundle_name = f"rayforge-vpirateray-{UPSTREAM['commit'][:12]}.zip"
|
bundle_name = f"rayforge-vpirateray-{UPSTREAM['commit'][:12]}.zip"
|
||||||
executable = bundle_name.removesuffix(".zip") + ".exe"
|
executable = bundle_name.removesuffix(".zip") + ".exe"
|
||||||
addon_files = {
|
addon_files = {
|
||||||
"rayforge-addon.yaml": b"name: the_pirate_ray_jcz\n",
|
path.relative_to(ADDON_SOURCE).as_posix(): path.read_bytes()
|
||||||
"pirate_ray_jcz/worker.py": b"TRANSPORT = None\n",
|
for path in sorted(ADDON_SOURCE.rglob("*"))
|
||||||
|
if path.is_file() and "__pycache__" not in path.parts
|
||||||
}
|
}
|
||||||
upstream = json.loads(
|
upstream = json.loads(
|
||||||
(ROOT / "upstreams" / "rayforge.json").read_text(encoding="utf-8")
|
(ROOT / "upstreams" / "rayforge.json").read_text(encoding="utf-8")
|
||||||
|
|
@ -29,6 +40,11 @@ class WindowsCheckpointVerifierTests(unittest.TestCase):
|
||||||
archive.writestr(f"{bundle_name[:-4]}/{executable}", b"synthetic")
|
archive.writestr(f"{bundle_name[:-4]}/{executable}", b"synthetic")
|
||||||
for relative, data in addon_files.items():
|
for relative, data in addon_files.items():
|
||||||
archive.writestr(f"{bundle_name[:-4]}/_internal/{ADDON_ROOT}/{relative}", data)
|
archive.writestr(f"{bundle_name[:-4]}/_internal/{ADDON_ROOT}/{relative}", data)
|
||||||
|
if extra_addon_file:
|
||||||
|
archive.writestr(
|
||||||
|
f"{bundle_name[:-4]}/_internal/{ADDON_ROOT}/{extra_addon_file}",
|
||||||
|
b"unexpected",
|
||||||
|
)
|
||||||
archive.writestr(
|
archive.writestr(
|
||||||
f"{bundle_name[:-4]}/THIRD_PARTY_LICENSES/Rayforge-LICENSE.txt",
|
f"{bundle_name[:-4]}/THIRD_PARTY_LICENSES/Rayforge-LICENSE.txt",
|
||||||
license_data,
|
license_data,
|
||||||
|
|
@ -79,7 +95,11 @@ class WindowsCheckpointVerifierTests(unittest.TestCase):
|
||||||
result = verify(self._checkpoint(Path(directory)))
|
result = verify(self._checkpoint(Path(directory)))
|
||||||
self.assertEqual(result["status"], "passed")
|
self.assertEqual(result["status"], "passed")
|
||||||
self.assertFalse(result["executed_binaries"])
|
self.assertFalse(result["executed_binaries"])
|
||||||
self.assertEqual(result["verified_addon_files"], 2)
|
expected_count = sum(
|
||||||
|
path.is_file() and "__pycache__" not in path.parts
|
||||||
|
for path in ADDON_SOURCE.rglob("*")
|
||||||
|
)
|
||||||
|
self.assertEqual(result["verified_addon_files"], expected_count)
|
||||||
|
|
||||||
def test_modified_installer_is_rejected(self):
|
def test_modified_installer_is_rejected(self):
|
||||||
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||||
|
|
@ -112,6 +132,30 @@ class WindowsCheckpointVerifierTests(unittest.TestCase):
|
||||||
with self.assertRaises(FileNotFoundError):
|
with self.assertRaises(FileNotFoundError):
|
||||||
verify(manifest)
|
verify(manifest)
|
||||||
|
|
||||||
|
def test_manifest_cannot_omit_or_change_repository_addon_source(self):
|
||||||
|
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||||
|
manifest = self._checkpoint(Path(directory))
|
||||||
|
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||||
|
data["addon_source_files"].pop()
|
||||||
|
manifest.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ValueError, "file list does not match"):
|
||||||
|
verify(manifest)
|
||||||
|
|
||||||
|
manifest = self._checkpoint(Path(directory))
|
||||||
|
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||||
|
data["addon_source_files"][0]["sha256"] = "0" * 64
|
||||||
|
manifest.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ValueError, "differs from repository"):
|
||||||
|
verify(manifest)
|
||||||
|
|
||||||
|
def test_unlisted_packaged_addon_file_is_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||||
|
manifest = self._checkpoint(
|
||||||
|
Path(directory), extra_addon_file="pirate_ray_jcz/transport.py"
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "file set does not match"):
|
||||||
|
verify(manifest)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue