diff --git a/.gitignore b/.gitignore index 6cd5e01..b4bbc9d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ coverage/ htmlcov/ .coverage coverage*.xml + +# Isolated source-build validation staging +/test-submodules/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30c78a3..0621972 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,9 +51,9 @@ package-version changes or publication and does not trigger or gate consumer submodule updates. Consumers are responsible for building both runtimes from their intended pinned -revision. Automated artifact-provenance enforcement and reliable build -invalidation remain roadmap S4 follow-up work rather than a package-version -release requirement. +revision. See [verified source builds](README.md#verified-source-builds) +for the paired build/verification command. Adopting these checks in consumer +pipelines remains a follow-up, not a package-version release requirement. ## Reporting Issues diff --git a/README.md b/README.md index 80c64dc..b892612 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,61 @@ def install_bundled_libs(session: nox.Session) -> None: The package manifests support building from the submodule. This repository no longer publishes packages to npm or PyPI. +### Verified source builds + +`npm run build` in `typescript` cleans `dist` before compiling, including outputs +for removed sources. A consumer's old `postinstall` existence check is **not** +source validation: explicitly rebuild after changing a submodule pin. + +For paired artifacts, use the standard-library build CLI with Node/npm, Git, +Python 3.10+, and `pip install setuptools wheel` available in an isolated build +environment. From this repository (PowerShell example): + +```powershell +$pin = git rev-parse HEAD +python scripts\source_build.py build --expected-commit $pin --output test-submodules\build +python scripts\source_build.py verify --expected-commit $pin --output test-submodules\build +``` + +In a consumer, invoke the script from its shared submodule and pass +`--source-root` pointing to that exact shared checkout, **not** the extension +root. The expected commit must be the full reviewed shared SHA, not the +consumer's SHA. Child-directory/ancestor Git resolution is rejected. + +The destination must not exist; builds never reuse a prior `dist`, wheel, +`node_modules` or setuptools output. Choose a new ignored `test-submodules` +subdirectory for each build (or explicitly remove only a known old staging +directory yourself). The CLI snapshots the shared runtime inputs, runs `npm ci` +and the existing TS build there, and builds a Python wheel without dependencies +or build isolation using the recorded local setuptools/wheel versions. Outputs +are `typescript/package.json`, `typescript/dist`, `wheels`, and `provenance.json`. +Toolchain/backend versions can affect output; this is deterministic source +selection and verified contents, **not** a cross-platform, byte-identical-wheel +or supply-chain attestation promise. + +The manifest records the shared checkout path/HEAD, whether it is dirty, and +content hashes of Git-visible runtime sources, manifests, lockfile, and compiler +configuration, plus hashes of every emitted artifact file. Verification checks +the selected source again, including additions/removals, and rejects missing, +extra or changed artifact files. Both runtimes are bound to one input fingerprint; a +manifest's commit label alone is insufficient. Neither package versions nor +runtime LSP payloads are changed. + +Dirty development builds require `--allow-dirty` on **both** commands. Their +identity is `commit + dirty + inputFingerprint`, never a clean commit claim. +Selected inputs are checked against HEAD even when Git index flags hide edits +or removals; Git's line-ending normalization is respected. +The fingerprint covers the listed build inputs, not arbitrary unrelated +repository edits. Release/adoption checks should require clean source. +Copy/install artifacts only after verification, and verify again before +packaging if the source or staging files could have changed. + +These outputs do not migrate consumer lockfiles. When the shared manifest +changes, consumers using `file:` installs must review their locked dependency +graph; installing the Python wheel with `--no-deps` also requires the consumer +to supply its full runtime dependency closure. Source/artifact verification +does not by itself prove consumer dependency compatibility. + ### Dependabot updates The consuming extensions configure Dependabot's `gitsubmodule` ecosystem to diff --git a/noxfile.py b/noxfile.py index 70e48ab..f5acc5f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -34,10 +34,12 @@ def lint(session: nox.Session) -> None: session.run("isort", "--check", "python/vscode_common_python_lsp") session.run("isort", "--check", "python/tests") - session.run("flake8", "scripts/localization.py", "scripts/localization_test.py") - session.run( - "black", "--check", "scripts/localization.py", "scripts/localization_test.py" - ) - session.run( - "isort", "--check", "scripts/localization.py", "scripts/localization_test.py" - ) + scripts = [ + "scripts/localization.py", + "scripts/localization_test.py", + "scripts/source_build.py", + "scripts/source_build_test.py", + ] + session.run("flake8", *scripts) + session.run("black", "--check", *scripts) + session.run("isort", "--check", *scripts) diff --git a/scripts/source_build.py b/scripts/source_build.py new file mode 100644 index 0000000..31c522b --- /dev/null +++ b/scripts/source_build.py @@ -0,0 +1,226 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Build and verify paired shared runtimes from an explicitly selected source.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +INPUTS = ( + "typescript/src", + "typescript/package.json", + "typescript/package-lock.json", + "typescript/tsconfig.json", + "python/vscode_common_python_lsp", + "python/pyproject.toml", + "python/README.md", +) + + +def run(args: list[str], cwd: Path, **kwargs) -> subprocess.CompletedProcess: + """Run a tool without a shell (including npm.cmd on Windows).""" + executable = shutil.which(args[0]) or args[0] + return subprocess.run([executable, *args[1:]], cwd=cwd, check=True, **kwargs) + + +def git(root: Path, *args: str) -> str: + return run( + ["git", *args], root, stdout=subprocess.PIPE, encoding="utf-8" + ).stdout.strip() + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def fingerprint(files: dict[str, str]) -> str: + return hashlib.sha256( + json.dumps(files, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def source_identity( + root: Path, expected_commit: str, allow_dirty: bool = False +) -> dict: + root = root.resolve() + if not re.fullmatch(r"[0-9a-f]{40}", expected_commit): + raise ValueError("Expected commit must be a full lowercase Git SHA") + if Path(git(root, "rev-parse", "--show-toplevel")).resolve() != root: + raise ValueError("Source root must be the shared Git checkout, not a child") + commit = git(root, "rev-parse", "HEAD") + if commit != expected_commit: + raise ValueError(f"Shared source pin mismatch: {commit} != {expected_commit}") + dirty = bool(git(root, "status", "--porcelain", "--untracked-files=normal")) + paths = git( + root, + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + "--", + *INPUTS, + ).split("\0") + files = {} + for name in sorted(set(filter(None, paths))): + path = root / name + if path.is_symlink(): + raise ValueError(f"Symlink build input is not supported: {name}") + if path.is_file(): + files[name] = digest(path) + for required in ("typescript/src/index.ts", "python/pyproject.toml"): + if required not in files: + raise ValueError(f"Missing shared build input: {required}") + if not dirty: + committed = {} + for entry in filter( + None, git(root, "ls-tree", "-r", "-z", "HEAD", "--", *INPUTS).split("\0") + ): + metadata, name = entry.split("\t", 1) + committed[name] = metadata.split()[-1] + # Ignore index shortcuts, but preserve Git's clean/line-ending filters. + blobs = git(root, "hash-object", "--", *files).splitlines() + dirty = dict(zip(files, blobs, strict=True)) != committed + if dirty and not allow_dirty: + raise ValueError("Dirty shared checkout; explicitly pass --allow-dirty") + return { + "commit": commit, + "dirty": dirty, + "inputFingerprint": fingerprint(files), + "inputs": files, + } + + +def artifact_files(output: Path) -> dict[str, str]: + unexpected = {path.name for path in output.iterdir()} - { + "typescript", + "wheels", + "provenance.json", + } + if unexpected: + raise ValueError(f"Unexpected artifact entries: {sorted(unexpected)}") + files = {} + for folder in ("typescript", "wheels"): + directory = output / folder + if not directory.is_dir(): + raise ValueError(f"Missing artifact directory: {directory}") + for path in sorted(directory.rglob("*")): + if path.is_symlink(): + raise ValueError(f"Symlink artifact is not supported: {path}") + if path.is_file(): + files[path.relative_to(output).as_posix()] = digest(path) + if "typescript/dist/index.js" not in files: + raise ValueError("Missing compiled TypeScript entry point") + if len(list((output / "wheels").glob("*.whl"))) != 1: + raise ValueError("Expected exactly one shared Python wheel") + return files + + +def verify( + root: Path, output: Path, expected_commit: str, allow_dirty: bool = False +) -> dict: + manifest = json.loads((output / "provenance.json").read_text(encoding="utf-8")) + if manifest.get("schema") != 1: + raise ValueError("Unknown source-build manifest schema") + current = source_identity(root, expected_commit, allow_dirty) + if current != manifest["source"]: + raise ValueError("Source changed since build (pin, dirty state or inputs)") + if artifact_files(output) != manifest["artifacts"]: + raise ValueError("Artifact contents changed since build") + return manifest + + +def build( + root: Path, output: Path, expected_commit: str, allow_dirty: bool = False +) -> dict: + root, output = root.resolve(), output.resolve() + identity = source_identity(root, expected_commit, allow_dirty) + if output.exists(): + raise ValueError(f"Refusing to reuse existing build destination: {output}") + output.mkdir(parents=True) + work = output / ".build" + snapshot = work / "source" + for name in identity["inputs"]: + target = snapshot / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(root / name, target) + # Hash the copied bytes too: a concurrent source edit cannot get a clean stamp. + if {name: digest(snapshot / name) for name in identity["inputs"]} != ( + identity["inputs"] + ): + raise ValueError("Source changed while taking build snapshot") + run(["npm", "ci", "--no-audit", "--no-fund"], snapshot / "typescript") + run(["npm", "run", "build"], snapshot / "typescript") + (output / "typescript").mkdir() + shutil.copyfile( + snapshot / "typescript/package.json", output / "typescript/package.json" + ) + shutil.copytree(snapshot / "typescript/dist", output / "typescript/dist") + (output / "wheels").mkdir() + run( + [ + sys.executable, + "-c", + "from setuptools.build_meta import build_wheel; " + "import sys; build_wheel(sys.argv[1])", + str(output / "wheels"), + ], + snapshot / "python", + ) + if identity != source_identity(root, expected_commit, allow_dirty): + raise ValueError("Source changed during build") + if {name: digest(snapshot / name) for name in identity["inputs"]} != ( + identity["inputs"] + ): + raise ValueError("Snapshot inputs changed during build") + shutil.rmtree(work) + manifest = { + "schema": 1, + "sourceRoot": str(root), + "source": identity, + "toolchain": { + "python": sys.version, + "setuptools": importlib.metadata.version("setuptools"), + "wheel": importlib.metadata.version("wheel"), + "node": run( + ["node", "--version"], root, stdout=subprocess.PIPE, text=True + ).stdout.strip(), + "npm": run( + ["npm", "--version"], root, stdout=subprocess.PIPE, text=True + ).stdout.strip(), + }, + "artifacts": artifact_files(output), + } + (output / "provenance.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + verify(root, output, expected_commit, allow_dirty) + return manifest + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("build", "verify")) + parser.add_argument("--source-root", type=Path, default=ROOT) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--expected-commit", required=True) + parser.add_argument("--allow-dirty", action="store_true") + args = parser.parse_args() + operation = build if args.operation == "build" else verify + result = operation( + args.source_root, args.output, args.expected_commit, args.allow_dirty + ) + print(json.dumps(result, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/scripts/source_build_test.py b/scripts/source_build_test.py new file mode 100644 index 0000000..27f125a --- /dev/null +++ b/scripts/source_build_test.py @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Regression tests for source identity and artifact consumption checks.""" + +import json +import tempfile +import unittest +from pathlib import Path + +import source_build as build + + +class SourceBuildTests(unittest.TestCase): + def setUp(self): + staging = build.ROOT / "test-submodules" + staging.mkdir(exist_ok=True) + self.workspace = tempfile.TemporaryDirectory( + prefix="source-build-", dir=staging + ) + self.addCleanup(self.workspace.cleanup) + self.root = Path(self.workspace.name) + build.git(self.root, "init", "--quiet") + for name, content in { + ".gitignore": "artifacts/\n", + "typescript/src/index.ts": "export const value = 1;\n", + "python/pyproject.toml": "[project]\nname = 'fixture'\n", + "python/vscode_common_python_lsp/__init__.py": "VALUE = 1\n", + }.items(): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + build.git(self.root, "add", ".") + build.git( + self.root, + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--quiet", + "-m", + "Fixture", + ) + self.commit = build.git(self.root, "rev-parse", "HEAD") + self.output = self.root / "artifacts" + (self.output / "typescript/dist").mkdir(parents=True) + (self.output / "typescript/dist/index.js").write_text("value = 1;\n") + (self.output / "wheels").mkdir() + (self.output / "wheels/shared.whl").write_bytes(b"fixture artifact") + self.manifest = { + "schema": 1, + "source": build.source_identity(self.root, self.commit), + "artifacts": build.artifact_files(self.output), + } + self.write_manifest() + + def write_manifest(self): + (self.output / "provenance.json").write_text( + json.dumps(self.manifest), encoding="utf-8" + ) + + def test_clean_build_identity_and_existing_destination(self): + self.assertEqual( + build.verify(self.root, self.output, self.commit), self.manifest + ) + with self.assertRaisesRegex(ValueError, "reuse existing"): + build.build(self.root, self.output, self.commit) + + def test_dirty_source_is_not_a_clean_commit(self): + source = self.root / "typescript/src/index.ts" + source.write_text("export const value = 2;\n") + with self.assertRaisesRegex(ValueError, "Dirty shared"): + build.source_identity(self.root, self.commit) + identity = build.source_identity(self.root, self.commit, allow_dirty=True) + self.assertTrue(identity["dirty"]) + self.assertEqual(identity["commit"], self.commit) + self.assertNotEqual( + identity["inputFingerprint"], + self.manifest["source"]["inputFingerprint"], + ) + with self.assertRaisesRegex(ValueError, "Source changed"): + build.verify(self.root, self.output, self.commit, allow_dirty=True) + + def test_hidden_runtime_edits_cannot_claim_a_clean_commit(self): + source = self.root / "python/vscode_common_python_lsp/__init__.py" + name = source.relative_to(self.root).as_posix() + original = source.read_bytes() + for flag in ("assume-unchanged", "skip-worktree"): + for operation in ("edit", "remove"): + with self.subTest(flag=flag, operation=operation): + build.git(self.root, "update-index", f"--{flag}", "--", name) + try: + if operation == "remove": + source.unlink() + else: + source.write_bytes(b"VALUE = 2\n") + with self.assertRaisesRegex(ValueError, "Dirty shared"): + build.source_identity(self.root, self.commit) + identity = build.source_identity( + self.root, self.commit, allow_dirty=True + ) + self.assertTrue(identity["dirty"]) + self.assertNotEqual( + identity["inputFingerprint"], + self.manifest["source"]["inputFingerprint"], + ) + finally: + source.write_bytes(original) + build.git(self.root, "update-index", f"--no-{flag}", "--", name) + + def test_clean_line_endings_use_git_normalization(self): + build.git(self.root, "config", "core.autocrlf", "true") + source = self.root / "python/vscode_common_python_lsp/__init__.py" + source.write_bytes(b"VALUE = 1\r\n") + identity = build.source_identity(self.root, self.commit) + self.assertFalse(identity["dirty"]) + + def test_source_removal_and_untracked_addition_invalidate_artifacts(self): + (self.root / "typescript/src/index.ts").write_text("export const value = 2;\n") + self.manifest["source"] = build.source_identity( + self.root, self.commit, allow_dirty=True + ) + self.write_manifest() + source = self.root / "python/vscode_common_python_lsp/__init__.py" + source.unlink() + with self.assertRaisesRegex(ValueError, "Source changed"): + build.verify(self.root, self.output, self.commit, allow_dirty=True) + build.git(self.root, "restore", "--", str(source.relative_to(self.root))) + source.with_name("new.py").write_text("NEW = True\n") + with self.assertRaisesRegex(ValueError, "Source changed"): + build.verify(self.root, self.output, self.commit, allow_dirty=True) + + def test_wrong_pin_and_consumer_ancestor_are_rejected(self): + with self.assertRaisesRegex(ValueError, "pin mismatch"): + build.verify(self.root, self.output, "0" * 40) + with self.assertRaisesRegex(ValueError, "not a child"): + build.source_identity(self.root / "typescript", self.commit) + + def test_artifact_tampering_missing_extra_and_mixed_python(self): + for name, operation in ( + ("typescript/dist/index.js", "edit"), + ("typescript/dist/index.js", "remove"), + ("typescript/dist/obsolete.js", "extra"), + ("unexpected.js", "extra"), + ("wheels/shared.whl", "edit"), + ): + with self.subTest(name=name, operation=operation): + path = self.output / name + original = path.read_bytes() if path.exists() else None + if operation == "remove": + path.unlink() + else: + path.write_bytes(b"not the recorded build") + with self.assertRaises(ValueError): + build.verify(self.root, self.output, self.commit) + if original is None: + path.unlink() + else: + path.write_bytes(original) + + def test_dirty_fingerprint_is_bound_to_both_artifacts(self): + source = self.root / "typescript/src/index.ts" + source.write_text("export const value = 3;\n") + self.manifest["source"] = build.source_identity( + self.root, self.commit, allow_dirty=True + ) + self.write_manifest() + build.verify(self.root, self.output, self.commit, allow_dirty=True) + source.write_text("export const value = 4;\n") + with self.assertRaisesRegex(ValueError, "Source changed"): + build.verify(self.root, self.output, self.commit, allow_dirty=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/typescript/package.json b/typescript/package.json index 6f18a2e..f0939f6 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -9,7 +9,7 @@ "dist/" ], "scripts": { - "build": "tsc", + "build": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && tsc", "watch": "tsc --watch", "lint": "eslint src", "format-check": "prettier --check src/**/*.ts",