Harden RF node control and agent updates
This commit is contained in:
319
tests/test_rf001_build_agent_packages.py
Normal file
319
tests/test_rf001_build_agent_packages.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Software-only tests for the authenticated bootstrap package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pathlib import PurePosixPath
|
||||
import shutil
|
||||
import subprocess
|
||||
import unicodedata
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.rf001_build_agent_packages as packages
|
||||
from tools.rf001_agent_update import (
|
||||
RELEASE_SOURCE_FILES,
|
||||
ReleaseValidationError,
|
||||
source_agent_version,
|
||||
)
|
||||
|
||||
|
||||
def _bootstrap_source(
|
||||
root: Path,
|
||||
*,
|
||||
agent_version: str = "2026.09.29.1",
|
||||
) -> Path:
|
||||
fixed = {
|
||||
"INSTALL_RX_AGENT.cmd": "@echo off\n",
|
||||
"UNINSTALL_RX_AGENT.cmd": "@echo off\n",
|
||||
"tools/rf001_bootstrap_readme.txt": "fixed bootstrap readme\n",
|
||||
"tools/rf001_agent_launcher.py": "# fixed launcher\n",
|
||||
"tools/install_rf001_rx_autostart.ps1": "# fixed installer\n",
|
||||
"tools/uninstall_rf001_rx_autostart.ps1": "# fixed uninstaller\n",
|
||||
}
|
||||
for name, content in fixed.items():
|
||||
path = root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
for index, relative in enumerate(RELEASE_SOURCE_FILES.values()):
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"# fixed release source {index}\n"
|
||||
if relative == RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]:
|
||||
content += f'AGENT_VERSION = "{agent_version}"\n'
|
||||
path.write_text(content, encoding="utf-8")
|
||||
reference = root / ".local/rf001_probe/lab065_reference_staircase.cs8"
|
||||
reference.parent.mkdir(parents=True, exist_ok=True)
|
||||
reference.write_bytes(b"fixed exact staircase fixture")
|
||||
return reference
|
||||
|
||||
|
||||
def _formed_installer_allowlist(release_id: str) -> list[str]:
|
||||
powershell = shutil.which("powershell.exe")
|
||||
if powershell is None:
|
||||
pytest.skip("Windows PowerShell is required to evaluate the installer allowlist")
|
||||
installer = Path(packages.__file__).with_name("install_rf001_rx_autostart.ps1")
|
||||
environment = os.environ.copy()
|
||||
environment["SDR_ROVER_INSTALLER_PATH"] = str(installer)
|
||||
environment["SDR_ROVER_RELEASE_ID"] = release_id
|
||||
command = r"""
|
||||
$ErrorActionPreference = "Stop"
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$syntax = [Management.Automation.Language.Parser]::ParseFile(
|
||||
$env:SDR_ROVER_INSTALLER_PATH,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors
|
||||
)
|
||||
if ($parseErrors.Count -ne 0) {
|
||||
throw "Installer contains PowerShell parse errors"
|
||||
}
|
||||
$assignments = @($syntax.FindAll({
|
||||
param($node)
|
||||
$node -is [Management.Automation.Language.AssignmentStatementAst] -and
|
||||
$node.Left -is [Management.Automation.Language.VariableExpressionAst] -and
|
||||
$node.Left.VariablePath.UserPath -eq "expectedPaths"
|
||||
}, $true))
|
||||
if ($assignments.Count -ne 1) {
|
||||
throw "Installer expectedPaths assignment is absent or ambiguous"
|
||||
}
|
||||
$releasePrefix = "versions/$($env:SDR_ROVER_RELEASE_ID)/"
|
||||
. ([scriptblock]::Create($assignments[0].Extent.Text))
|
||||
ConvertTo-Json -Compress -InputObject @($expectedPaths)
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[powershell, "-NoProfile", "-NonInteractive", "-Command", command],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
env=environment,
|
||||
)
|
||||
value = json.loads(completed.stdout.lstrip("\ufeff"))
|
||||
assert isinstance(value, list)
|
||||
assert all(isinstance(path, str) for path in value)
|
||||
return value
|
||||
|
||||
|
||||
def test_bootstrap_is_authenticated_and_excludes_secret(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "bootstrap.zip"
|
||||
built = packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.1",
|
||||
agent_release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
checked = packages.inspect_bootstrap_archive(package, token_path=token)
|
||||
assert checked == built
|
||||
assert checked.file_count == len(RELEASE_SOURCE_FILES) + 8
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
names = archive.namelist()
|
||||
manifest = json.loads(archive.read(packages.BOOTSTRAP_MANIFEST_NAME))
|
||||
ready = json.loads(
|
||||
archive.read("versions/rf001-agent-2026.09.29.1/READY.json")
|
||||
)
|
||||
assert not any("token" in name.casefold() for name in names)
|
||||
assert not any(
|
||||
b"fixed-test-token" in archive.read(name)
|
||||
for name in names
|
||||
)
|
||||
actual_version = source_agent_version(
|
||||
source / RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]
|
||||
)
|
||||
assert manifest["agent_version"] == actual_version
|
||||
assert ready["agent_version"] == actual_version
|
||||
|
||||
|
||||
def test_new_bootstrap_preserves_existing_release_bytes(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
base_package = tmp_path / "bootstrap-4.zip"
|
||||
next_package = tmp_path / "bootstrap-5.zip"
|
||||
release_id = "rf001-agent-2026.09.29.1"
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
base_package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.4",
|
||||
agent_release_id=release_id,
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
(source / "tools/rf001_agent_launcher.py").write_text(
|
||||
"# corrected launcher\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
next_package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.5",
|
||||
agent_release_id=release_id,
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
agent_release_bootstrap_id="rf001-bootstrap-2026.09.29.4",
|
||||
)
|
||||
|
||||
release_prefix = f"versions/{release_id}/"
|
||||
with zipfile.ZipFile(base_package) as base, zipfile.ZipFile(next_package) as new:
|
||||
release_names = sorted(
|
||||
name for name in base.namelist() if name.startswith(release_prefix)
|
||||
)
|
||||
assert release_names
|
||||
assert all(base.read(name) == new.read(name) for name in release_names)
|
||||
assert base.read("launcher/rf001_agent_launcher.py") != new.read(
|
||||
"launcher/rf001_agent_launcher.py"
|
||||
)
|
||||
manifest = json.loads(new.read(packages.BOOTSTRAP_MANIFEST_NAME))
|
||||
ready = json.loads(new.read(f"{release_prefix}READY.json"))
|
||||
assert manifest["bootstrap_id"] == "rf001-bootstrap-2026.09.29.5"
|
||||
assert (
|
||||
manifest["agent_release_bootstrap_id"]
|
||||
== "rf001-bootstrap-2026.09.29.4"
|
||||
)
|
||||
assert ready["bootstrap_id"] == "rf001-bootstrap-2026.09.29.4"
|
||||
|
||||
|
||||
def test_bootstrap_rejects_the_wrong_existing_secret(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "bootstrap.zip"
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.1",
|
||||
agent_release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
wrong = tmp_path / "wrong.txt"
|
||||
wrong.write_text("another-fixed-token-0123456789abcdef", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="signature mismatch"):
|
||||
packages.inspect_bootstrap_archive(package, token_path=wrong)
|
||||
|
||||
|
||||
def test_installer_allowlist_matches_signed_manifest_and_has_safe_paths(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "bootstrap.zip"
|
||||
release_id = "rf001-agent-2026.09.29.1"
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.2",
|
||||
agent_release_id=release_id,
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
manifest = json.loads(archive.read(packages.BOOTSTRAP_MANIFEST_NAME))
|
||||
manifest_paths = {row["path"] for row in manifest["files"]}
|
||||
formed_paths = _formed_installer_allowlist(release_id)
|
||||
|
||||
assert set(formed_paths) == manifest_paths
|
||||
assert len(formed_paths) == len(manifest_paths)
|
||||
assert f"versions/{release_id}/READY.json" in formed_paths
|
||||
assert (
|
||||
f"versions/{release_id}/app/tools/hackrf_process_check.py" in formed_paths
|
||||
)
|
||||
assert (
|
||||
f"versions/{release_id}/app/tools/rf001_staircase_search.py" in formed_paths
|
||||
)
|
||||
for path in formed_paths:
|
||||
assert not any(unicodedata.category(character) == "Cc" for character in path)
|
||||
parsed = PurePosixPath(path)
|
||||
assert not parsed.is_absolute()
|
||||
assert all(part not in ("", ".", "..") for part in parsed.parts)
|
||||
assert "\\" not in path
|
||||
assert not any(":" in part for part in parsed.parts)
|
||||
|
||||
with zipfile.ZipFile(package, "a") as archive:
|
||||
archive.writestr("unexpected/extra.txt", b"prohibited")
|
||||
with pytest.raises(ValueError, match="unexpected files"):
|
||||
packages.inspect_bootstrap_archive(package, token_path=token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_version", "requested_version"),
|
||||
(
|
||||
("2026.09.29.2", "2026.09.29.1"),
|
||||
("2026.09.29.1", "2026.09.29.2"),
|
||||
),
|
||||
)
|
||||
def test_bootstrap_rejects_agent_version_mismatch_before_creating_artifact(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
source_version: str,
|
||||
requested_version: str,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source, agent_version=source_version)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "must-not-exist.zip"
|
||||
with pytest.raises(ReleaseValidationError, match="AGENT_VERSION|release_id"):
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.3",
|
||||
agent_release_id=f"rf001-agent-{requested_version}",
|
||||
agent_version=requested_version,
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
assert not package.exists()
|
||||
Reference in New Issue
Block a user