216 lines
7.3 KiB
Python
216 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import gzip
|
|
import hashlib
|
|
import io
|
|
import os
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
|
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
|
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
|
|
|
from jwebot_kernel import ( # noqa: E402
|
|
ReleaseError,
|
|
ReleaseRecord,
|
|
build_release,
|
|
parse_manifest,
|
|
verify_release,
|
|
)
|
|
|
|
|
|
def manifest():
|
|
return parse_manifest(
|
|
{
|
|
"id": "hello-command",
|
|
"author": "tester",
|
|
"description": "Say hello",
|
|
"help": ["/hello"],
|
|
"version": "1.0.0",
|
|
"api_version": "1",
|
|
"entrypoint": "hello_command:plugin",
|
|
"type": "command",
|
|
"capabilities": {
|
|
"commands": ["/hello"],
|
|
"storage": False,
|
|
"network_profiles": [],
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def extension(root: Path) -> Path:
|
|
root.mkdir()
|
|
(root / "src").mkdir()
|
|
(root / "plugin.toml").write_text(
|
|
'id = "hello-command"\n',
|
|
encoding="utf-8",
|
|
)
|
|
(root / "README.md").write_text("# hello\n", encoding="utf-8")
|
|
(root / "src" / "hello_command.py").write_text(
|
|
"\n".join(
|
|
(
|
|
"from jwebot_sdk import ExtensionResult, ReplyPlan",
|
|
"",
|
|
"def handle(event, arguments):",
|
|
" return ExtensionResult(reply=ReplyPlan.text('hello'))",
|
|
)
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return root
|
|
|
|
|
|
class ReleaseTests(unittest.TestCase):
|
|
def test_release_is_deterministic_across_mtime_changes(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
source = extension(base / "extension")
|
|
first, _, first_record = build_release(
|
|
source,
|
|
base / "first",
|
|
manifest=manifest(),
|
|
)
|
|
os.utime(source / "src" / "hello_command.py", (123456789, 123456789))
|
|
second, _, second_record = build_release(
|
|
source,
|
|
base / "second",
|
|
manifest=manifest(),
|
|
)
|
|
self.assertEqual(first.read_bytes(), second.read_bytes())
|
|
self.assertEqual(first_record.source_digest, second_record.source_digest)
|
|
self.assertEqual(
|
|
first_record.artifact_sha256,
|
|
second_record.artifact_sha256,
|
|
)
|
|
|
|
def test_built_release_verifies_without_extraction(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
artifact, _, record = build_release(
|
|
extension(base / "extension"),
|
|
base / "output",
|
|
manifest=manifest(),
|
|
)
|
|
verify_release(artifact, record)
|
|
self.assertEqual(record.author, "tester")
|
|
self.assertEqual(
|
|
record.files,
|
|
("README.md", "plugin.toml", "src/hello_command.py"),
|
|
)
|
|
|
|
def test_artifact_tampering_is_rejected(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
artifact, _, record = build_release(
|
|
extension(base / "extension"),
|
|
base / "output",
|
|
manifest=manifest(),
|
|
)
|
|
content = bytearray(artifact.read_bytes())
|
|
content[-1] ^= 0x01
|
|
artifact.write_bytes(content)
|
|
with self.assertRaisesRegex(ReleaseError, "digest mismatch"):
|
|
verify_release(artifact, record)
|
|
|
|
def test_release_record_rejects_invalid_author(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
_, _, record = build_release(
|
|
extension(base / "extension"),
|
|
base / "output",
|
|
manifest=manifest(),
|
|
)
|
|
value = record.to_mapping()
|
|
value["author"] = "../tester"
|
|
with self.assertRaisesRegex(ReleaseError, "invalid release record"):
|
|
ReleaseRecord.from_mapping(value)
|
|
|
|
def test_unsupported_source_file_is_rejected(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
source = extension(base / "extension")
|
|
(source / "src" / "payload.bin").write_bytes(b"payload")
|
|
with self.assertRaisesRegex(ReleaseError, "unsupported release file"):
|
|
build_release(
|
|
source,
|
|
base / "output",
|
|
manifest=manifest(),
|
|
)
|
|
|
|
def test_interpreter_cache_is_excluded_from_release(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
source = extension(base / "extension")
|
|
cache = source / "src" / "__pycache__"
|
|
cache.mkdir()
|
|
(cache / "hello_command.cpython-313.pyc").write_bytes(b"cache")
|
|
|
|
_, _, record = build_release(
|
|
source,
|
|
base / "output",
|
|
manifest=manifest(),
|
|
)
|
|
|
|
self.assertEqual(
|
|
record.files,
|
|
("README.md", "plugin.toml", "src/hello_command.py"),
|
|
)
|
|
|
|
def test_immutable_artifact_cannot_be_replaced(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
source = extension(base / "extension")
|
|
artifact, _, _ = build_release(
|
|
source,
|
|
base / "output",
|
|
manifest=manifest(),
|
|
)
|
|
artifact.write_bytes(b"tampered")
|
|
with self.assertRaisesRegex(ReleaseError, "immutable release"):
|
|
build_release(
|
|
source,
|
|
base / "output",
|
|
manifest=manifest(),
|
|
)
|
|
|
|
def test_unsafe_archive_member_is_rejected(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
artifact = Path(directory) / "unsafe.tar.gz"
|
|
with artifact.open("wb") as raw:
|
|
with gzip.GzipFile(
|
|
filename="",
|
|
mode="wb",
|
|
fileobj=raw,
|
|
mtime=0,
|
|
) as compressed:
|
|
with tarfile.open(fileobj=compressed, mode="w") as archive:
|
|
content = b"unsafe"
|
|
info = tarfile.TarInfo("plugin/../outside.py")
|
|
info.size = len(content)
|
|
archive.addfile(info, io.BytesIO(content))
|
|
artifact_bytes = artifact.read_bytes()
|
|
record = ReleaseRecord(
|
|
plugin_id="hello-command",
|
|
author="tester",
|
|
plugin_version="1.0.0",
|
|
api_version="1",
|
|
source_digest="0" * 64,
|
|
artifact_name=artifact.name,
|
|
artifact_sha256=hashlib.sha256(artifact_bytes).hexdigest(),
|
|
artifact_size=len(artifact_bytes),
|
|
files=("outside.py",),
|
|
)
|
|
with self.assertRaisesRegex(ReleaseError, "unsafe member"):
|
|
verify_release(artifact, record)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|