Files
Claro/tools/validate_package_security.py
T

269 lines
13 KiB
Python

#!/usr/bin/env python3
import os
import subprocess
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EXE = ROOT / ("claro.exe" if os.name == "nt" else "claro")
EXPECTED_VERSION = "Claro v1.18.26"
def fail(message):
raise SystemExit(message)
def run(cmd, cwd):
result = subprocess.run(cmd, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
return result.returncode, result.stdout
def read(path):
return path.read_text(encoding="utf-8")
def main():
if not EXE.exists():
fail(f"Missing executable: {EXE}")
rc, out = run([str(EXE), "--version"], ROOT)
if rc != 0 or EXPECTED_VERSION not in out:
fail(f"Expected {EXPECTED_VERSION}, got:\n{out}")
with tempfile.TemporaryDirectory() as td:
root = Path(td)
work = root / "work"
work.mkdir()
rc, out = run([str(EXE), "new", "../bad"], work)
if rc == 0:
fail("Unsafe project names with path separators must be rejected")
if "Project names may use only" not in out:
fail(f"Unsafe project-name diagnostic was unclear:\n{out}")
if (root / "bad").exists():
fail("Unsafe project name should not create a folder outside the current project area")
long_project_name = "P" * 65
rc, out = run([str(EXE), "new", long_project_name], work)
if rc == 0:
fail("Project names longer than 64 characters must be rejected before paths are created")
if "Project names must be 64 characters or fewer" not in out:
fail(f"Long project-name diagnostic was unclear:\n{out}")
if (work / long_project_name).exists():
fail("Long project name should not create a project folder")
rc, out = run([str(EXE), "new", "StarterApp"], work)
if rc != 0:
fail(out)
starter = work / "StarterApp"
starter_project = read(starter / "claro.project")
for phrase in ["manifest-version: 1", "name: StarterApp", "main: main.claro", "version: v1.18.26", "packages:"]:
if phrase not in starter_project:
fail(f"claro new project file missing {phrase!r}:\n{starter_project}")
starter_lock = read(starter / "claro.lock")
for phrase in ["lock-version: 1", "version: v1.18.26"]:
if phrase not in starter_lock:
fail(f"claro new lockfile missing {phrase!r}:\n{starter_lock}")
(work / "main.claro").write_text('SAY "Package security demo"\n', encoding="utf-8")
rc, out = run([str(EXE), "package", "init"], work)
if rc != 0:
fail(out)
project = read(work / "claro.project")
for phrase in ["manifest-version: 1", "name: ClaroProject", "main: main.claro", "version: v1.18.26", "packages:"]:
if phrase not in project:
fail(f"claro.project missing {phrase!r}:\n{project}")
(work / "claro.project").write_text(
project.replace("manifest-version: 1", "manifest-version: 10", 1),
encoding="utf-8",
)
rc, out = run([str(EXE), "package", "doctor"], work)
if rc == 0:
fail("package doctor must reject a project manifest version that only starts with the supported version")
if "BAD project manifest version: expected 1" not in out:
fail(f"Package project manifest-version diagnostic was unclear:\n{out}")
(work / "claro.project").write_text(project, encoding="utf-8")
rc, out = run([str(EXE), "package", "add", "math-tools"], work)
if rc != 0:
fail(out)
manifest_path = work / "packages" / "math-tools" / "claro.package"
manifest = read(manifest_path)
for phrase in ["manifest-version: 1", "name: math-tools", "version: 1", "source: local", "checksum:"]:
if phrase not in manifest:
fail(f"claro.package missing {phrase!r}:\n{manifest}")
checksum_line = next((line for line in manifest.splitlines() if line.startswith("checksum:")), "")
checksum = checksum_line.split(":", 1)[1].strip()
if len(checksum) < 8 or checksum == "TODO":
fail(f"Package checksum should be a stable non-placeholder value, got {checksum!r}")
lock = read(work / "claro.lock")
for phrase in ["lock-version: 1", "version: v1.18.26", "package: math-tools", "checksum:"]:
if phrase not in lock:
fail(f"claro.lock missing {phrase!r}:\n{lock}")
(work / "claro.lock").write_text(
lock.replace("checksum:", "checksum: bad", 1),
encoding="utf-8",
)
rc, out = run([str(EXE), "package", "doctor"], work)
if rc == 0:
fail("package doctor must reject stale lockfile checksums")
if "BAD lock checksum: math-tools" not in out:
fail(f"package doctor stale-lock diagnostic was unclear:\n{out}")
(work / "claro.lock").write_text(lock, encoding="utf-8")
checksum_line = next((line for line in manifest.splitlines() if line.startswith("checksum:")), "")
checksum = checksum_line.split(":", 1)[1].strip()
manifest_path.write_text(manifest.replace("checksum: " + checksum, "checksum: " + checksum + "-extra"), encoding="utf-8")
rc, out = run([str(EXE), "package", "doctor"], work)
if rc == 0:
fail("package doctor must reject a manifest checksum with trailing data")
if "BAD package checksum: math-tools" not in out:
fail(f"Package manifest checksum diagnostic was unclear:\n{out}")
manifest_path.write_text(manifest, encoding="utf-8")
manifest_path.write_text(manifest.replace("manifest-version: 1", "manifest-version: 10"), encoding="utf-8")
rc, out = run([str(EXE), "package", "doctor"], work)
if rc == 0:
fail("package doctor must reject a manifest version that only starts with the supported version")
if "MISSING package manifest: math-tools" not in out:
fail(f"Package manifest-version diagnostic was unclear:\n{out}")
manifest_path.write_text(manifest, encoding="utf-8")
manifest_path.write_text(manifest.replace("version: 1", "version: 10"), encoding="utf-8")
rc, out = run([str(EXE), "package", "doctor"], work)
if rc == 0:
fail("package doctor must reject a package version that only starts with the supported version")
if "BAD package version: math-tools" not in out:
fail(f"Package manifest version diagnostic was unclear:\n{out}")
manifest_path.write_text(manifest, encoding="utf-8")
manifest_path.write_text(manifest.replace("source: local", "source: local-extra"), encoding="utf-8")
rc, out = run([str(EXE), "package", "doctor"], work)
if rc == 0:
fail("package doctor must reject a package source that only starts with the supported source")
if "BAD package source: math-tools" not in out:
fail(f"Package manifest source diagnostic was unclear:\n{out}")
manifest_path.write_text(manifest, encoding="utf-8")
rc, out = run([str(EXE), "package", "doctor"], work)
if rc != 0:
fail(out)
for phrase in ["OK package manifest: math-tools", "OK package checksum: math-tools", "OK lock checksum: math-tools", "Package/project files look ready."]:
if phrase not in out:
fail(f"package doctor output missing {phrase!r}:\n{out}")
manifest_path.unlink()
rc, out = run([str(EXE), "package", "doctor"], work)
if rc == 0:
fail("package doctor must reject a listed package whose manifest is missing")
if "MISSING package manifest: math-tools" not in out:
fail(f"package doctor missing-manifest diagnostic was unclear:\n{out}")
manifest_path.write_text(manifest.replace("name: math-tools", "name: other-tools"), encoding="utf-8")
rc, out = run([str(EXE), "package", "doctor"], work)
if rc == 0:
fail("package doctor must reject a manifest whose name does not match the project package")
if "BAD package manifest name: math-tools" not in out:
fail(f"package doctor manifest-name diagnostic was unclear:\n{out}")
manifest_path.write_text(manifest.replace("name: math-tools", "name: math-tools-extra"), encoding="utf-8")
rc, out = run([str(EXE), "package", "doctor"], work)
if rc == 0:
fail("package doctor must reject a manifest name that only starts with the project package name")
if "BAD package manifest name: math-tools" not in out:
fail(f"Package manifest prefix-match diagnostic was unclear:\n{out}")
manifest_path.write_text(manifest, encoding="utf-8")
(work / "claro.lock").write_text(
lock + "package: ghost\nchecksum: 12345678\n",
encoding="utf-8",
)
rc, out = run([str(EXE), "package", "doctor"], work)
if rc == 0:
fail("package doctor must reject lockfile packages that are not listed in claro.project")
if "BAD lock package not in claro.project: ghost" not in out:
fail(f"package doctor orphan-lock diagnostic was unclear:\n{out}")
(work / "claro.lock").write_text(lock, encoding="utf-8")
rc, out = run([str(EXE), "package", "add", "../bad"], work)
if rc == 0:
fail("Unsafe package names with path separators must be rejected")
if "Package names may use only" not in out:
fail(f"Unsafe package diagnostic was unclear:\n{out}")
long_name = "a" * 65
rc, out = run([str(EXE), "package", "add", long_name], work)
if rc == 0:
fail("Package names longer than 64 characters must be rejected before paths are created")
if "Package names must be 64 characters or fewer" not in out:
fail(f"Long package-name diagnostic was unclear:\n{out}")
if (work / "packages" / long_name).exists():
fail("Long package name should not create a package folder")
(work / "claro.project").write_text(
"manifest-version: 1\nname: ClaroProject\nmain: main.claro\nversion: v1.18.26\npackages:\npackage: ../bad\n",
encoding="utf-8",
)
rc, out = run([str(EXE), "package", "doctor"], work)
if rc == 0:
fail("package doctor must reject unsafe package names already present in claro.project")
if "BAD package name: ../bad" not in out:
fail(f"package doctor unsafe-name diagnostic was unclear:\n{out}")
rc, out = run([str(EXE), "package", "lock"], work)
if rc == 0:
fail("package lock must reject unsafe package names already present in claro.project")
if "BAD package name: ../bad" not in out:
fail(f"package lock unsafe-name diagnostic was unclear:\n{out}")
rc, out = run([str(EXE), "package", "list"], work)
if rc == 0:
fail("package list must reject unsafe package names already present in claro.project")
if "BAD package name: ../bad" not in out:
fail(f"package list unsafe-name diagnostic was unclear:\n{out}")
rc, out = run([str(EXE), "package", "init"], work)
if rc == 0:
fail("package init must not report success while unsafe package names remain in claro.project")
if "BAD package name: ../bad" not in out:
fail(f"package init existing unsafe-name diagnostic was unclear:\n{out}")
rc, out = run([str(EXE), "package", "add", "science"], work)
if rc == 0:
fail("package add must not report success while unsafe package names remain in claro.project")
if "BAD package name: ../bad" not in out:
fail(f"package add existing unsafe-name diagnostic was unclear:\n{out}")
project_after_failed_add = read(work / "claro.project")
if "package: science" in project_after_failed_add:
fail("package add should not append a package while unsafe names remain in claro.project")
if (work / "packages" / "science").exists():
fail("package add should not create a package folder while unsafe names remain in claro.project")
(work / "claro.project").write_text(
"manifest-version: 1\nname: ClaroProject\nmain: main.claro\nversion: v1.18.26\npackages:\npackage: math-tools\npackage: ../bad\n",
encoding="utf-8",
)
rc, out = run([str(EXE), "package", "remove", "math-tools"], work)
if rc == 0:
fail("package remove must not report success while unsafe package names remain in claro.project")
if "BAD package name: ../bad" not in out:
fail(f"package remove unsafe-name diagnostic was unclear:\n{out}")
rc, out = run([str(EXE), "package", "remove", "../bad"], work)
if rc != 0:
fail(f"package remove should let learners remove an unsafe package entry from claro.project:\n{out}")
if "Removed package from project: ../bad" not in out:
fail(f"package remove unsafe-entry recovery output was unclear:\n{out}")
project_after_bad_remove = read(work / "claro.project")
if "package: ../bad" in project_after_bad_remove:
fail("package remove should remove the unsafe package entry from claro.project")
lock_after_bad_remove = read(work / "claro.lock")
if "package: ../bad" in lock_after_bad_remove:
fail("package remove should not keep unsafe package entries in claro.lock after recovery")
print("Package security validation OK")
if __name__ == "__main__":
main()