diff --git a/Makefile b/Makefile index b8328f171..0027425db 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ export TMPDIR := /var/tmp PYTEST_BASETEMP_INIT := BASETEMP=$$(mktemp -d /var/tmp/solstone-pytest-XXXXXX); trap 'rm -rf "$$BASETEMP"' EXIT INT TERM; PYTEST_BASETEMP_FLAG := --basetemp "$$BASETEMP" -.PHONY: install hopper-install uninstall test test-cov test-integration test-performance test-app test-only format format-check install-checks ci clean clean-install coverage watch versions update update-prices preflight pre-commit skills render-packaging check-rust-fmt check-rust-msrv check-rust-clippy check-rust-test check-rust-ios check-rust-deny check-release-advisory-liveness check-rust-release-manifest audit openapi check-openapi check-openapi-observer-client-contract contract check-contract journal-resolution-vectors check-journal-resolution-vectors build-native-sol-grammar-oracle check-native-sol-grammar-oracle build-native-sol-root-contract check-native-sol-root-contract build-native-sol-journal-host-commands check-native-sol-journal-host-commands build-journal-access-rejection-inventory check-journal-access-rejection-inventory check-native-sol-python-manifest build-native-sol-inventory check-native-sol-inventory check-native-sol-architecture check-native-sol-contract-routes check-native-sol-conformance check-native-sol-coverage check-native-sol-no-python-spawn check-native-sol-compat check-native-sol-docs-links check-removed-time-parser-ready dev all sandbox sandbox-stop install-models parakeet-helper parakeet-helper-clean wheel-macos wheel-macos-clean verify verify-api verify-schemathesis update-api-baselines eval-schemas service-logs check-layer-hygiene check-api-conventions check-journal-io-access check-journal-io-mechanic check-journal-config-owner check-call-http-only check-no-legacy-chat check-channel-adapter-scrub check-brain-health-cutover check-tools-http-only check-access-imports-clean check-convey-bind-imports-clean check-schema-bounds check-thin-base-install check-extras-consistency check-cogitate-prompts smoke-cogitate release release-test publish-release publish-release-test FORCE +.PHONY: install hopper-install uninstall test test-cov test-integration test-performance test-app test-only format format-check install-checks ci clean clean-install coverage watch versions update update-prices preflight pre-commit skills render-packaging check-rust-fmt check-rust-msrv check-rust-clippy check-rust-test check-rust-ios check-rust-deny check-release-advisory-liveness check-rust-release-manifest audit openapi check-openapi check-openapi-observer-client-contract contract check-contract journal-resolution-vectors check-journal-resolution-vectors build-native-sol-grammar-oracle check-native-sol-grammar-oracle build-native-sol-root-contract check-native-sol-root-contract check-core-sdist-compile-inputs build-native-sol-journal-host-commands check-native-sol-journal-host-commands build-journal-access-rejection-inventory check-journal-access-rejection-inventory check-native-sol-python-manifest build-native-sol-inventory check-native-sol-inventory check-native-sol-architecture check-native-sol-contract-routes check-native-sol-conformance check-native-sol-coverage check-native-sol-no-python-spawn check-native-sol-compat check-native-sol-docs-links check-removed-time-parser-ready dev all sandbox sandbox-stop install-models parakeet-helper parakeet-helper-clean wheel-macos wheel-macos-clean verify verify-api verify-schemathesis update-api-baselines eval-schemas service-logs check-layer-hygiene check-api-conventions check-journal-io-access check-journal-io-mechanic check-journal-config-owner check-call-http-only check-no-legacy-chat check-channel-adapter-scrub check-brain-health-cutover check-tools-http-only check-access-imports-clean check-convey-bind-imports-clean check-schema-bounds check-thin-base-install check-extras-consistency check-cogitate-prompts smoke-cogitate release release-test publish-release publish-release-test FORCE # Default target - install package in editable mode all: install @@ -535,6 +535,9 @@ install-checks: .installed @echo "=== Checking native sol root contract ===" @$(MAKE) check-native-sol-root-contract @echo "" + @echo "=== Checking core sdist compile inputs ===" + @$(MAKE) check-core-sdist-compile-inputs + @echo "" @echo "=== Checking native sol journal-host command inventory ===" @$(MAKE) check-native-sol-journal-host-commands @echo "" @@ -778,6 +781,9 @@ build-native-sol-root-contract: check-native-sol-root-contract: python3 scripts/check_native_sol_root_contract.py +check-core-sdist-compile-inputs: + python3 scripts/check_core_sdist_compile_inputs.py + build-native-sol-journal-host-commands: python3 scripts/build_native_sol_journal_host_commands.py diff --git a/docs/PORTING.md b/docs/PORTING.md index 977f88db9..9511e520b 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -45,6 +45,7 @@ target, document the blocker and stop the conversion before merging it. | Rust dependency policy | `make check-rust-deny` | GNU-host check | Locked, offline bans/licenses/sources policy over the supported cargo-deny graph. | | Rust advisories | `make audit` | GNU-host check | Verifies a signed advisory mirror packet, materializes its bundle locally, then performs a locked offline advisory check without refreshing or mutating the operator inputs. | | iOS canary | `make check-rust-ios` | iOS cross-target canary | Cross-target drift evidence for eligible library crates; explicitly excludes `solstone-core-indexer-store` because the native SQLite store is not yet in the iOS gate. | +| Core sdist compile inputs | `make check-core-sdist-compile-inputs` | Packaging-source check | Verifies shipping Rust compile-time inputs are discovered and covered by the normalized `solstone-core` sdist injection set. | | Release candidate rail | `scripts/release.sh --candidate` / `scripts/release.sh --recover ` | Local readiness evidence | DESTRUCTIVE: `--candidate` is fresh construction; before policy or build work it deletes prior raw build/dist outputs and that version's stale payload/evidence. It binds candidate payload, ledger, and per-target install/smoke proofs, then reports canonical local readiness JSON. `--recover` is retained-byte-only, read-only validation; it preserves retained payload, ledger, and proofs and never rebuilds or refreshes. Proofs cover local candidate bytes and native smoke only; publication is temporarily locked out of this rail. | ### Signed Advisory Mirror Audit diff --git a/scripts/check_core_sdist_compile_inputs.py b/scripts/check_core_sdist_compile_inputs.py new file mode 100644 index 000000000..6d18c4378 --- /dev/null +++ b/scripts/check_core_sdist_compile_inputs.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Check that shipping Rust compile-time inputs are covered by the core sdist.""" + +from __future__ import annotations + +import argparse +import logging +import tarfile +from dataclasses import dataclass +from pathlib import Path + +try: + from scripts.core_compile_inputs import ( + CoreCompileInputAsset, + CoreCompileInputError, + discover_core_compile_inputs, + ) + from scripts.normalize_maturin_sdist import core_sdist_injected_files +except ModuleNotFoundError: # pragma: no cover - direct script execution path. + from core_compile_inputs import ( # type: ignore[no-redef] + CoreCompileInputAsset, + CoreCompileInputError, + discover_core_compile_inputs, + ) + from normalize_maturin_sdist import ( # type: ignore[no-redef] + core_sdist_injected_files, + ) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Violation: + file: str + kind: str + detail: str + + +def collect_violations(root: Path, *, sdist: Path | None = None) -> list[Violation]: + root = root.resolve() + violations: list[Violation] = [] + try: + assets = discover_core_compile_inputs(root) + except CoreCompileInputError as exc: + return [Violation("core", "compile-input-discovery-failed", str(exc))] + if not assets: + violations.append( + Violation( + "core", + "compile-input-discovery-empty", + "shipping solstone-core compile-input discovery returned no assets", + ) + ) + return violations + violations.extend(_injection_mapping_violations(root, assets)) + if sdist is not None: + violations.extend(_archive_violations(root, assets, sdist)) + return violations + + +def _injection_mapping_violations( + root: Path, assets: tuple[CoreCompileInputAsset, ...] +) -> list[Violation]: + try: + injected = core_sdist_injected_files(root) + except Exception as exc: # noqa: BLE001 - gate must report script-boundary failures. + return [Violation("core", "sdist-injection-mapping-failed", str(exc))] + violations: list[Violation] = [] + for asset in assets: + actual = injected.get(asset.sdist_path) + if actual is None: + violations.append( + Violation( + _rel(root, asset.source_file), + "compile-input-not-injected", + f"{asset.sdist_path} is absent from the normalizer injection mapping", + ) + ) + continue + expected = asset.resolved_path.read_bytes() + if actual != expected: + violations.append( + Violation( + _rel(root, asset.source_file), + "compile-input-injected-bytes-mismatch", + f"{asset.sdist_path} bytes differ from source", + ) + ) + return violations + + +def _archive_violations( + root: Path, assets: tuple[CoreCompileInputAsset, ...], sdist: Path +) -> list[Violation]: + try: + with tarfile.open(sdist, "r:gz") as archive: + members = archive.getmembers() + roots = {Path(member.name).parts[0] for member in members if member.name} + if len(roots) != 1: + return [ + Violation( + str(sdist), + "sdist-root-invalid", + f"expected one archive root, found {sorted(roots)}", + ) + ] + archive_root = next(iter(roots)) + by_name = {member.name: member for member in members} + violations: list[Violation] = [] + for asset in assets: + member_name = f"{archive_root}/{asset.sdist_path}" + member = by_name.get(member_name) + if member is None: + violations.append( + Violation( + _rel(root, asset.source_file), + "compile-input-archive-member-missing", + f"{member_name} is absent from {sdist}", + ) + ) + continue + if not member.isfile(): + violations.append( + Violation( + _rel(root, asset.source_file), + "compile-input-archive-member-not-regular", + f"{member_name} is not a regular file", + ) + ) + continue + extracted = archive.extractfile(member) + if extracted is None: + violations.append( + Violation( + _rel(root, asset.source_file), + "compile-input-archive-member-unreadable", + f"{member_name} could not be read", + ) + ) + continue + if extracted.read() != asset.resolved_path.read_bytes(): + violations.append( + Violation( + _rel(root, asset.source_file), + "compile-input-archive-bytes-mismatch", + f"{member_name} bytes differ from source", + ) + ) + return violations + except (OSError, tarfile.TarError) as exc: + return [Violation(str(sdist), "sdist-unreadable", str(exc))] + + +def _rel(root: Path, path: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError: + return str(path) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--sdist", type=Path) + args = parser.parse_args() + logging.basicConfig(format="%(message)s", level=logging.INFO) + violations = collect_violations(REPO_ROOT, sdist=args.sdist) + if violations: + LOGGER.error("core sdist compile-input violations:") + for violation in violations: + LOGGER.error( + "- %s: %s: %s", violation.file, violation.kind, violation.detail + ) + return 1 + LOGGER.info("core sdist compile inputs ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/core_compile_inputs.py b/scripts/core_compile_inputs.py new file mode 100644 index 000000000..8692aa24a --- /dev/null +++ b/scripts/core_compile_inputs.py @@ -0,0 +1,594 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Discover non-test compile-time inputs for the shipping solstone-core bins.""" + +from __future__ import annotations + +import re +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +ROOT_PACKAGE = "solstone-core" +INCLUDE_MACROS = frozenset({"include_str", "include_bytes"}) + + +class CoreCompileInputError(RuntimeError): + """A shipping Rust compile-time input could not be derived safely.""" + + +@dataclass(frozen=True) +class CoreCompileInputAsset: + source_file: Path + macro: Literal["include_str", "include_bytes"] + line: int + column: int + raw_argument: str + resolved_path: Path + sdist_path: str + + +@dataclass(frozen=True) +class _Package: + name: str + member: str + manifest: Path + data: dict[str, Any] + + +def discover_core_compile_inputs(root: Path) -> tuple[CoreCompileInputAsset, ...]: + root = root.resolve() + packages = _workspace_packages(root) + closure = _shipping_closure(root, packages) + source_files = _shipping_source_files(root, packages, closure) + records: list[CoreCompileInputAsset] = [] + for source_file in source_files: + text = _read_text(source_file) + filtered = _without_cfg_test_items(source_file, text) + records.extend(_include_records(root, source_file, filtered)) + return tuple( + sorted(records, key=lambda item: (item.sdist_path, item.source_file, item.line)) + ) + + +def core_compile_input_sdist_files(root: Path) -> dict[str, bytes]: + files: dict[str, bytes] = {} + for asset in discover_core_compile_inputs(root): + try: + data = asset.resolved_path.read_bytes() + except OSError as exc: + raise CoreCompileInputError( + f"compile-input-read-failed: {asset.resolved_path}: {exc}" + ) from None + existing = files.get(asset.sdist_path) + if existing is not None and existing != data: + raise CoreCompileInputError( + f"compile-input-conflict: {asset.sdist_path} has multiple byte sources" + ) + files[asset.sdist_path] = data + return files + + +def _workspace_packages(root: Path) -> dict[str, _Package]: + workspace_manifest = root / "core" / "Cargo.toml" + data = _read_toml(workspace_manifest, label="core workspace") + members = data.get("workspace", {}).get("members") + if not isinstance(members, list) or not members: + raise CoreCompileInputError( + "workspace-members-invalid: core workspace has no members" + ) + packages: dict[str, _Package] = {} + for member in members: + if not isinstance(member, str) or not member: + raise CoreCompileInputError( + f"workspace-member-invalid: invalid workspace member {member!r}" + ) + manifest = root / "core" / member / "Cargo.toml" + member_data = _read_toml(manifest, label=f"workspace member {member}") + name = member_data.get("package", {}).get("name") + if not isinstance(name, str) or not name: + raise CoreCompileInputError( + f"package-name-missing: {manifest.relative_to(root).as_posix()}" + ) + if name in packages: + raise CoreCompileInputError(f"package-name-duplicate: {name}") + packages[name] = _Package(name, member, manifest, member_data) + if ROOT_PACKAGE not in packages: + raise CoreCompileInputError(f"root-package-missing: {ROOT_PACKAGE}") + return packages + + +def _read_toml(path: Path, *, label: str) -> dict[str, Any]: + try: + return tomllib.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise CoreCompileInputError(f"manifest-missing: {label}: {path}") from None + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + raise CoreCompileInputError(f"manifest-invalid: {label}: {exc}") from None + + +def _shipping_closure(root: Path, packages: dict[str, _Package]) -> tuple[str, ...]: + workspace_data = _read_toml(root / "core" / "Cargo.toml", label="core workspace") + workspace_deps = workspace_data.get("workspace", {}).get("dependencies", {}) + if not isinstance(workspace_deps, dict): + raise CoreCompileInputError("workspace-dependencies-invalid") + seen: set[str] = set() + ordered: list[str] = [] + + def visit(name: str) -> None: + if name in seen: + return + package = packages.get(name) + if package is None: + raise CoreCompileInputError(f"closure-package-missing: {name}") + seen.add(name) + ordered.append(name) + dependencies = package.data.get("dependencies", {}) + if not isinstance(dependencies, dict): + raise CoreCompileInputError(f"dependencies-invalid: {package.name}") + for dep_name in _normal_workspace_dependency_names( + root, package, dependencies, workspace_deps + ): + if dep_name in packages: + visit(dep_name) + + visit(ROOT_PACKAGE) + return tuple(ordered) + + +def _normal_workspace_dependency_names( + root: Path, + package: _Package, + dependencies: dict[str, Any], + workspace_deps: dict[str, Any], +) -> tuple[str, ...]: + names: list[str] = [] + for key, value in dependencies.items(): + dep_manifest: Path | None = None + if isinstance(value, dict) and value.get("workspace") is True: + workspace_value = workspace_deps.get(key) + if isinstance(workspace_value, dict) and isinstance( + workspace_value.get("path"), str + ): + dep_manifest = root / "core" / workspace_value["path"] / "Cargo.toml" + elif isinstance(value, dict) and isinstance(value.get("path"), str): + dep_manifest = package.manifest.parent / value["path"] / "Cargo.toml" + if dep_manifest is None: + continue + dep_data = _read_toml(dep_manifest, label=f"dependency {key}") + dep_name = dep_data.get("package", {}).get("name") + if not isinstance(dep_name, str) or not dep_name: + raise CoreCompileInputError( + f"dependency-package-name-missing: {dep_manifest}" + ) + names.append(dep_name) + return tuple(names) + + +def _shipping_source_files( + root: Path, packages: dict[str, _Package], closure: tuple[str, ...] +) -> tuple[Path, ...]: + pending: list[Path] = [] + root_package = packages[ROOT_PACKAGE] + pending.extend(_root_bin_targets(root_package)) + for name in closure: + if name == ROOT_PACKAGE: + continue + pending.append(_lib_target(packages[name])) + + seen: set[Path] = set() + ordered: list[Path] = [] + while pending: + source = pending.pop() + source = source.resolve() + if source in seen: + continue + if not source.is_file(): + raise CoreCompileInputError(f"source-file-missing: {source}") + seen.add(source) + ordered.append(source) + text = _read_text(source) + filtered = _without_cfg_test_items(source, text) + pending.extend(_module_files(source, filtered)) + return tuple(sorted(ordered)) + + +def _root_bin_targets(package: _Package) -> tuple[Path, ...]: + targets: list[Path] = [] + for raw_bin in package.data.get("bin", []): + if not isinstance(raw_bin, dict): + raise CoreCompileInputError(f"bin-target-invalid: {package.name}") + path = raw_bin.get("path") + if isinstance(path, str) and path: + targets.append(package.manifest.parent / path) + if package.data.get("package", {}).get("autobins") is not False: + implicit = package.manifest.parent / "src" / "main.rs" + if implicit.is_file(): + targets.append(implicit) + if not targets: + raise CoreCompileInputError(f"root-bin-target-missing: {package.name}") + return tuple(dict.fromkeys(targets)) + + +def _lib_target(package: _Package) -> Path: + raw_lib = package.data.get("lib") + if isinstance(raw_lib, dict) and isinstance(raw_lib.get("path"), str): + return package.manifest.parent / raw_lib["path"] + return package.manifest.parent / "src" / "lib.rs" + + +def _read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + raise CoreCompileInputError(f"source-file-missing: {path}") from None + except (OSError, UnicodeDecodeError) as exc: + raise CoreCompileInputError(f"source-file-unreadable: {path}: {exc}") from None + + +def _module_files(source: Path, text: str) -> tuple[Path, ...]: + masked = _masked_rust(source, text) + modules: list[Path] = [] + pattern = re.compile( + r"(?:(?:pub)(?:\s*\([^)]*\))?\s+)?mod\s+" + r"(?P[A-Za-z_][A-Za-z0-9_]*)\s*(?P[;{])", + re.MULTILINE, + ) + for match in pattern.finditer(masked): + if match.group("tail") != ";": + continue + path_attr = _path_attribute_before(source, text, masked, match.start()) + if path_attr is not None: + relative = _parse_string_literal( + path_attr, source, _line_for_offset(text, match.start()) + ) + target = source.parent / relative + if not target.is_file(): + raise CoreCompileInputError( + f"path-module-missing: {source}:{_line_for_offset(text, match.start())}: {relative}" + ) + modules.append(target) + continue + name = match.group("name") + base = ( + source.parent + if source.stem in {"lib", "main", "mod"} + else source.parent / source.stem + ) + candidates = (base / f"{name}.rs", base / name / "mod.rs") + for candidate in candidates: + if candidate.is_file(): + modules.append(candidate) + break + else: + raise CoreCompileInputError( + f"module-file-missing: {source}:{_line_for_offset(text, match.start())}: mod {name}" + ) + return tuple(modules) + + +def _path_attribute_before( + source: Path, text: str, masked: str, offset: int +) -> str | None: + index = offset + while index > 0 and masked[index - 1].isspace(): + index -= 1 + if index == 0 or masked[index - 1] != "]": + return None + attr_start = masked.rfind("#", 0, index) + if attr_start == -1: + return None + attr_masked = masked[attr_start:index] + if not re.fullmatch(r"#\s*\[\s*path\s*=\s*[\s]*\]", attr_masked, re.DOTALL): + return None + attr_text = text[attr_start:index] + match = re.fullmatch(r"#\s*\[\s*path\s*=\s*(?P.+)\s*\]", attr_text, re.DOTALL) + if match is None: + raise CoreCompileInputError( + f"path-attribute-invalid: {source}:{_line_for_offset(text, attr_start)}" + ) + return match.group("path").strip() + + +def _include_records( + root: Path, source: Path, text: str +) -> tuple[CoreCompileInputAsset, ...]: + masked = _masked_rust(source, text) + records: list[CoreCompileInputAsset] = [] + pattern = re.compile(r"\b(?Pinclude_str|include_bytes)\s*!\s*\(") + for match in pattern.finditer(masked): + macro = match.group("macro") + close = _matching_delimiter(masked, match.end() - 1, "(", ")") + raw_argument = text[match.end() : close].strip() + line = _line_for_offset(text, match.start()) + column = _column_for_offset(text, match.start()) + include_path = _parse_string_literal(raw_argument, source, line) + resolved = (source.parent / include_path).resolve(strict=False) + if not resolved.is_relative_to(root): + raise CoreCompileInputError( + f"compile-input-outside-repo: {source}:{line}: {include_path}" + ) + if resolved.is_symlink() or not resolved.is_file(): + raise CoreCompileInputError( + f"compile-input-missing: {source}:{line}: {include_path}" + ) + records.append( + CoreCompileInputAsset( + source_file=source.resolve(), + macro=macro, # type: ignore[arg-type] + line=line, + column=column, + raw_argument=raw_argument, + resolved_path=resolved, + sdist_path=resolved.relative_to(root).as_posix(), + ) + ) + return tuple(records) + + +def _without_cfg_test_items(source: Path, text: str) -> str: + masked = _masked_rust(source, text) + output = list(text) + pattern = re.compile(r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]") + for match in reversed(tuple(pattern.finditer(masked))): + start = match.start() + end = _cfg_test_item_end(source, text, masked, match.end()) + for index in range(start, end): + if output[index] != "\n": + output[index] = " " + return "".join(output) + + +def _cfg_test_item_end(source: Path, text: str, masked: str, offset: int) -> int: + index = _skip_ws(masked, offset) + while index < len(masked) and masked[index] == "#": + attr_end = masked.find("]", index) + if attr_end == -1: + raise CoreCompileInputError( + f"cfg-test-excision-failed: {source}:{_line_for_offset(text, index)}: unterminated attribute" + ) + index = _skip_ws(masked, attr_end + 1) + item_start = _skip_visibility(masked, index) + if _starts_word(masked, item_start, "mod") or _starts_word( + masked, item_start, "fn" + ): + brace = masked.find("{", item_start) + semicolon = masked.find(";", item_start) + if brace == -1 or (semicolon != -1 and semicolon < brace): + raise CoreCompileInputError( + f"cfg-test-excision-failed: {source}:{_line_for_offset(text, item_start)}: expected item body" + ) + return _matching_delimiter(masked, brace, "{", "}") + 1 + raise CoreCompileInputError( + f"cfg-test-excision-failed: {source}:{_line_for_offset(text, item_start)}: unsupported cfg(test) item" + ) + + +def _skip_visibility(masked: str, index: int) -> int: + if not _starts_word(masked, index, "pub"): + return index + index = _skip_ws(masked, index + 3) + if index < len(masked) and masked[index] == "(": + index = _matching_delimiter(masked, index, "(", ")") + 1 + return _skip_ws(masked, index) + + +def _skip_ws(text: str, index: int) -> int: + while index < len(text) and text[index].isspace(): + index += 1 + return index + + +def _starts_word(text: str, index: int, word: str) -> bool: + end = index + len(word) + if text[index:end] != word: + return False + before = index == 0 or not (text[index - 1].isalnum() or text[index - 1] == "_") + after = end == len(text) or not (text[end].isalnum() or text[end] == "_") + return before and after + + +def _matching_delimiter(text: str, offset: int, open_char: str, close_char: str) -> int: + if offset >= len(text) or text[offset] != open_char: + raise CoreCompileInputError("delimiter-match-failed: opening delimiter missing") + depth = 0 + for index in range(offset, len(text)): + char = text[index] + if char == open_char: + depth += 1 + elif char == close_char: + depth -= 1 + if depth == 0: + return index + raise CoreCompileInputError("delimiter-match-failed: unbalanced delimiters") + + +def _masked_rust(source: Path, text: str) -> str: + chars = list(text) + index = 0 + while index < len(chars): + char = text[index] + next_char = text[index + 1] if index + 1 < len(text) else "" + if char == "/" and next_char == "/": + index = _mask_line_comment(chars, text, index) + elif char == "/" and next_char == "*": + index = _mask_block_comment(source, chars, text, index) + elif _raw_string_prefix(text, index) is not None: + index = _mask_raw_string(source, chars, text, index) + elif (char == "b" and next_char == '"') or char == '"': + index = _mask_quoted( + source, + chars, + text, + index + (1 if char == "b" else 0), + '"', + allow_newline=True, + ) + elif char == "b" and next_char == "'": + index = _mask_quoted( + source, + chars, + text, + index + 1, + "'", + allow_newline=False, + ) + elif char == "'" and _looks_like_char_literal(text, index): + index = _mask_quoted(source, chars, text, index, "'", allow_newline=False) + else: + index += 1 + return "".join(chars) + + +def _mask_line_comment(chars: list[str], text: str, index: int) -> int: + while index < len(chars) and text[index] != "\n": + chars[index] = " " + index += 1 + return index + + +def _mask_block_comment(source: Path, chars: list[str], text: str, index: int) -> int: + depth = 0 + while index < len(chars): + pair = text[index : index + 2] + if pair == "/*": + depth += 1 + chars[index] = chars[index + 1] = " " + index += 2 + continue + if pair == "*/": + depth -= 1 + chars[index] = chars[index + 1] = " " + index += 2 + if depth == 0: + return index + continue + if text[index] != "\n": + chars[index] = " " + index += 1 + raise CoreCompileInputError( + f"unterminated-rust-comment: {source}:{_line_for_offset(text, len(text))}" + ) + + +def _raw_string_prefix(text: str, index: int) -> tuple[int, str] | None: + if text.startswith("br", index): + index += 2 + elif text.startswith("r", index): + index += 1 + else: + return None + hashes_start = index + while index < len(text) and text[index] == "#": + index += 1 + if index >= len(text) or text[index] != '"': + return None + return index + 1, "#" * (index - hashes_start) + + +def _mask_raw_string(source: Path, chars: list[str], text: str, index: int) -> int: + prefix = _raw_string_prefix(text, index) + if prefix is None: + return index + 1 + content_start, hashes = prefix + terminator = '"' + hashes + end = text.find(terminator, content_start) + if end == -1: + raise CoreCompileInputError( + f"unterminated-rust-string: {source}:{_line_for_offset(text, index)}" + ) + end += len(terminator) + for cursor in range(index, end): + if text[cursor] != "\n": + chars[cursor] = " " + return end + + +def _mask_quoted( + source: Path, + chars: list[str], + text: str, + quote_index: int, + quote: str, + *, + allow_newline: bool, +) -> int: + index = quote_index + chars[index] = " " + index += 1 + while index < len(chars): + char = text[index] + if char == "\n": + if not allow_newline: + raise CoreCompileInputError( + f"unterminated-rust-string: {source}:{_line_for_offset(text, quote_index)}" + ) + index += 1 + continue + chars[index] = " " + if char == "\\": + index += 1 + if index < len(chars): + chars[index] = " " + index += 1 + continue + if char == quote: + return index + 1 + index += 1 + raise CoreCompileInputError( + f"unterminated-rust-string: {source}:{_line_for_offset(text, quote_index)}" + ) + + +def _looks_like_char_literal(text: str, index: int) -> bool: + if index + 1 >= len(text) or text[index + 1] == "\n": + return False + if (text[index + 1].isalnum() or text[index + 1] == "_") and ( + index + 2 >= len(text) or text[index + 2] != "'" + ): + return False + cursor = index + 1 + while cursor < len(text) and text[cursor] != "\n": + if text[cursor] == "\\": + cursor += 2 + continue + if text[cursor] == "'": + return cursor - index <= 16 + cursor += 1 + return False + + +def _parse_string_literal(raw: str, source: Path, line: int) -> str: + raw = raw.strip() + raw_match = re.fullmatch( + r'r(?P#*)"(?P.*)"(?P=hashes)', raw, re.DOTALL + ) + if raw_match is not None: + return raw_match.group("body") + if not (raw.startswith('"') and raw.endswith('"')): + raise CoreCompileInputError( + f"unsupported-include-argument: {source}:{line}: {raw}" + ) + body = raw[1:-1] + if "\n" in body: + raise CoreCompileInputError( + f"unsupported-include-argument: {source}:{line}: multiline string literal" + ) + try: + return bytes(body, "utf-8").decode("unicode_escape") + except UnicodeDecodeError as exc: + raise CoreCompileInputError( + f"unsupported-include-argument: {source}:{line}: invalid escape {exc}" + ) from None + + +def _line_for_offset(text: str, offset: int) -> int: + return text.count("\n", 0, offset) + 1 + + +def _column_for_offset(text: str, offset: int) -> int: + line_start = text.rfind("\n", 0, offset) + 1 + return offset - line_start + 1 diff --git a/scripts/normalize_maturin_sdist.py b/scripts/normalize_maturin_sdist.py index 1a3a82207..bba5af8b9 100644 --- a/scripts/normalize_maturin_sdist.py +++ b/scripts/normalize_maturin_sdist.py @@ -17,6 +17,17 @@ import tempfile import tomllib from pathlib import Path, PurePosixPath +try: + from scripts.core_compile_inputs import ( + CoreCompileInputError, + core_compile_input_sdist_files, + ) +except ModuleNotFoundError: # pragma: no cover - direct script execution path. + from core_compile_inputs import ( # type: ignore[no-redef] + CoreCompileInputError, + core_compile_input_sdist_files, + ) + class SdistLockError(RuntimeError): """The built sdist cannot be normalized without weakening its lock.""" @@ -26,7 +37,7 @@ PACKAGE_BLOCK_RE = re.compile(r"(?ms)^\[\[package\]\]\n.*?(?=^\[\[package\]\]\n| DEPENDENCY_RE = re.compile( r"^(?P\S+)(?: (?P\S+)(?: \((?P.+)\))?)?$" ) -NATIVE_SOL_SOURCE_GLOBS = ( +CORE_SDIST_GLOB_INJECTION_PATTERNS = ( "solstone/apps/*/native/*", "solstone/think/native/**/*", "solstone/think/tools/native/**/*", @@ -237,9 +248,9 @@ def _read_archive( raise SdistLockError(f"sdist archive is unreadable: {exc}") from None -def _native_sol_sdist_files(root: Path) -> dict[str, bytes]: +def _globbed_core_sdist_injected_files(root: Path) -> dict[str, bytes]: files: dict[str, bytes] = {} - for pattern in NATIVE_SOL_SOURCE_GLOBS: + for pattern in CORE_SDIST_GLOB_INJECTION_PATTERNS: for path in sorted(root.glob(pattern)): if path.is_dir(): continue @@ -258,6 +269,22 @@ def _native_sol_sdist_files(root: Path) -> dict[str, bytes]: return files +def core_sdist_injected_files(root: Path) -> dict[str, bytes]: + files = _globbed_core_sdist_injected_files(root) + try: + compile_inputs = core_compile_input_sdist_files(root) + except CoreCompileInputError as exc: + raise SdistLockError(f"core compile input discovery failed: {exc}") from exc + for relative, content in compile_inputs.items(): + existing = files.get(relative) + if existing is not None and existing != content: + raise SdistLockError( + f"core sdist injected member {relative} has conflicting byte sources" + ) + files[relative] = content + return files + + def _replace_archive_files( archive: Path, *, @@ -365,23 +392,23 @@ def normalize_core_sdist_workspace_lock(root: Path, archive: Path) -> tuple[str, raise SdistLockError("retained source workspace package names are not unique") if retained_names & pruned_names: raise SdistLockError("source workspace package names are not unique") - native_files = { + injected_files = { f"{archive_root}/{relative}": content - for relative, content in _native_sol_sdist_files(root).items() + for relative, content in core_sdist_injected_files(root).items() } if not pruned_names: if _archive_needs_update( entries=entries, lock_name=f"{archive_root}/core/Cargo.lock", lock_bytes=lock_bytes, - extra_files=native_files, + extra_files=injected_files, ): _replace_archive_files( archive, entries=entries, lock_name=f"{archive_root}/core/Cargo.lock", lock_bytes=lock_bytes, - extra_files=native_files, + extra_files=injected_files, ) return () @@ -394,7 +421,7 @@ def normalize_core_sdist_workspace_lock(root: Path, archive: Path) -> tuple[str, entries=entries, lock_name=f"{archive_root}/core/Cargo.lock", lock_bytes=rewritten, - extra_files=native_files, + extra_files=injected_files, ): return () _replace_archive_files( @@ -402,6 +429,6 @@ def normalize_core_sdist_workspace_lock(root: Path, archive: Path) -> tuple[str, entries=entries, lock_name=f"{archive_root}/core/Cargo.lock", lock_bytes=rewritten, - extra_files=native_files, + extra_files=injected_files, ) return tuple(sorted(pruned_names)) diff --git a/tests/test_core_compile_inputs.py b/tests/test_core_compile_inputs.py new file mode 100644 index 000000000..9f1584251 --- /dev/null +++ b/tests/test_core_compile_inputs.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.core_compile_inputs import ( + CoreCompileInputError, + discover_core_compile_inputs, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_real_tree_discovers_only_shipping_root_contract() -> None: + assets = discover_core_compile_inputs(REPO_ROOT) + + assert len(assets) == 1 + asset = assets[0] + assert ( + asset.source_file + == REPO_ROOT / "core/crates/solstone-core-sol-client-cli/src/help.rs" + ) + assert asset.line == 8 + assert asset.sdist_path == "core/fixtures/native-sol/root-contract-v1.json" + excluded_sources = { + REPO_ROOT / "core/crates/solstone-core-indexer-store/src/db.rs", + REPO_ROOT / "core/crates/solstone-core-indexer/src/chunker.rs", + REPO_ROOT / "core/crates/solstone-core-sol-client-cli/tests/parity.rs", + REPO_ROOT + / "core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs", + } + assert asset.source_file not in excluded_sources + + +def test_target_conditional_module_is_discovered_on_non_matching_host( + tmp_path: Path, +) -> None: + _write_workspace( + tmp_path, + helper_lib='#[cfg(target_os = "definitely_not_solstone")] mod gated;\n', + extra={ + "core/crates/solstone-core-helper/src/gated.rs": ( + 'const ASSET: &str = include_str!("asset.txt");\n' + ), + "core/crates/solstone-core-helper/src/asset.txt": "asset\n", + }, + ) + + assets = discover_core_compile_inputs(tmp_path) + + assert [asset.sdist_path for asset in assets] == [ + "core/crates/solstone-core-helper/src/asset.txt" + ] + + +def test_cfg_test_regions_are_removed_before_include_scan(tmp_path: Path) -> None: + _write_workspace( + tmp_path, + helper_lib=( + "#[cfg(test)]\n" + "mod tests {\n" + ' const TEST_ONLY: &str = r#"{"path":"missing.txt"}"#;\n' + ' const IGNORED: &str = include_str!("missing.txt");\n' + "}\n" + 'const ASSET: &str = include_str!("asset.txt");\n' + ), + extra={"core/crates/solstone-core-helper/src/asset.txt": "asset\n"}, + ) + + assets = discover_core_compile_inputs(tmp_path) + + assert [asset.sdist_path for asset in assets] == [ + "core/crates/solstone-core-helper/src/asset.txt" + ] + + +@pytest.mark.parametrize( + ("helper_lib", "message"), + [ + ( + 'const ASSET: &str = include_str!(concat!("asset", ".txt"));\n', + "unsupported-include-argument", + ), + ( + 'const ASSET: &str = include_str!("../../../../../outside.txt");\n', + "outside-repo", + ), + ('const ASSET: &str = include_str!("missing.txt");\n', "compile-input-missing"), + ('#[path = "missing.rs"] mod missing;\n', "path-module-missing"), + ], +) +def test_discovery_failures_are_loud( + tmp_path: Path, helper_lib: str, message: str +) -> None: + _write_workspace(tmp_path, helper_lib=helper_lib) + + with pytest.raises(CoreCompileInputError, match=message): + discover_core_compile_inputs(tmp_path) + + +def test_unterminated_cfg_test_region_fails_loudly(tmp_path: Path) -> None: + _write_workspace(tmp_path, helper_lib="#[cfg(test)]\nmod tests {\n") + + with pytest.raises(CoreCompileInputError, match="delimiter-match-failed"): + discover_core_compile_inputs(tmp_path) + + +def _write_workspace( + root: Path, + *, + helper_lib: str, + extra: dict[str, str] | None = None, +) -> None: + _write( + root / "core/Cargo.toml", + ( + '[workspace]\nmembers = ["crates/solstone-core", ' + '"crates/solstone-core-helper"]\nresolver = "3"\n\n' + "[workspace.dependencies]\n" + 'solstone-core-helper = { path = "crates/solstone-core-helper" }\n' + ), + ) + _write( + root / "core/crates/solstone-core/Cargo.toml", + ( + '[package]\nname = "solstone-core"\nversion = "1.2.3"\n' + "\n[dependencies]\nsolstone-core-helper.workspace = true\n" + '\n[[bin]]\nname = "sol"\npath = "src/main.rs"\n' + ), + ) + _write(root / "core/crates/solstone-core/src/main.rs", "fn main() {}\n") + _write( + root / "core/crates/solstone-core-helper/Cargo.toml", + '[package]\nname = "solstone-core-helper"\nversion = "1.2.3"\n', + ) + _write(root / "core/crates/solstone-core-helper/src/lib.rs", helper_lib) + for relative, content in (extra or {}).items(): + _write(root / relative, content) + + +def _write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") diff --git a/tests/test_core_sdist_compile_inputs_integration.py b/tests/test_core_sdist_compile_inputs_integration.py new file mode 100644 index 000000000..b5287ce9e --- /dev/null +++ b/tests/test_core_sdist_compile_inputs_integration.py @@ -0,0 +1,307 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import gzip +import shutil +import subprocess +import sys +import tarfile +import tomllib +from collections.abc import Callable +from io import BytesIO +from pathlib import Path + +import pytest + +from scripts.core_compile_inputs import ( + CoreCompileInputAsset, + discover_core_compile_inputs, +) +from scripts.normalize_maturin_sdist import normalize_core_sdist_workspace_lock +from scripts.release_candidate_driver import ( + CORE_X86_64_MATURIN_ARGS, + _scrubbed_build_env, +) +from scripts.release_tool_pins import RUSTC_RELEASE_PIN + +REPO_ROOT = Path(__file__).resolve().parents[1] +IGNORE_NAMES = { + ".git", + ".venv", + "journal", + "target", + "dist", + "htmlcov", + ".pytest_cache", + "__pycache__", + "node_modules", + "logs", + "scratch", + "tmp", +} +ESCAPED_INCLUDE_ARGUMENT = "../../../../../../missing-outside-extracted-root.txt" + + +@pytest.mark.integration +@pytest.mark.timeout(900) +def test_core_sdist_compile_inputs_are_required_by_real_wheel_build( + tmp_path: Path, +) -> None: + _require_build_tools() + source_root = _copy_source_root(tmp_path) + sdist = _build_normalized_sdist(source_root) + assets = discover_core_compile_inputs(source_root) + assert len(assets) == 1 + asset = assets[0] + + _run_gate_archive_mode(source_root, sdist) + + control = _build_wheel(tmp_path, "control", sdist) + assert control.returncode == 0, control.combined_output + + # The checkout contains this asset at the same relative layout. If the build + # could reach sideways into checkout source, this removal case would pass. + removed = _mutated_sdist( + tmp_path, sdist, "removed", lambda root: _remove_asset(root, asset) + ) + removed_result = _build_wheel(tmp_path, "removed", removed) + _assert_missing_include_failure(removed_result, asset) + + wrong_path = _mutated_sdist( + tmp_path, + sdist, + "wrong-path", + lambda root: _move_asset_to_wrong_path(root, asset), + ) + wrong_path_result = _build_wheel(tmp_path, "wrong-path", wrong_path) + _assert_missing_include_failure(wrong_path_result, asset) + + redirected = _mutated_sdist( + tmp_path, + sdist, + "redirected", + lambda root: _redirect_include_outside_root(root, asset), + ) + redirected_result = _build_wheel(tmp_path, "redirected", redirected) + _assert_redirected_include_failure(redirected_result, asset) + + +def _copy_source_root(tmp_path: Path) -> Path: + source_root = tmp_path / "source" + shutil.copytree(REPO_ROOT, source_root, ignore=_ignore_entries) + return source_root + + +def _ignore_entries(_directory: str, names: list[str]) -> set[str]: + ignored = set() + for name in names: + if name in IGNORE_NAMES or name.startswith(".sandbox."): + ignored.add(name) + return ignored + + +def _build_normalized_sdist(source_root: Path) -> Path: + env = _build_env(source_root, source_root / "cargo-target-sdist", "") + result = subprocess.run( + ("uv", "build", "--package", "solstone-core", "--sdist"), + cwd=source_root, + env=env, + text=True, + capture_output=True, + timeout=900, + ) + assert result.returncode == 0, result.stdout + result.stderr + archives = sorted((source_root / "dist").glob("solstone_core-*.tar.gz")) + assert len(archives) == 1 + sdist = archives[0] + normalize_core_sdist_workspace_lock(source_root, sdist) + return sdist + + +def _run_gate_archive_mode(source_root: Path, sdist: Path) -> None: + result = subprocess.run( + ( + sys.executable, + str(source_root / "scripts" / "check_core_sdist_compile_inputs.py"), + "--sdist", + str(sdist), + ), + cwd=source_root, + text=True, + capture_output=True, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +class _BuildResult: + def __init__(self, result: subprocess.CompletedProcess[str]) -> None: + self.returncode = result.returncode + self.combined_output = result.stdout + result.stderr + + +def _build_wheel(tmp_path: Path, name: str, sdist: Path) -> _BuildResult: + case = tmp_path / "wheel-builds" / name + out_dir = case / "wheel" + target_dir = case / "target" + out_dir.mkdir(parents=True) + target_dir.mkdir() + env = _build_env(case, target_dir, CORE_X86_64_MATURIN_ARGS) + result = subprocess.run( + ("uv", "build", str(sdist), "--wheel", "--out-dir", str(out_dir)), + cwd=case, + env=env, + text=True, + capture_output=True, + timeout=900, + ) + return _BuildResult(result) + + +def _build_env(root: Path, target_dir: Path, maturin_args: str) -> dict[str, str]: + env = _scrubbed_build_env(root, maturin_args) + env["CARGO_TARGET_DIR"] = str(target_dir) + env["CARGO_INCREMENTAL"] = "0" + env["CARGO_NET_OFFLINE"] = "true" + env["RUSTUP_TOOLCHAIN"] = _pinned_toolchain() + return env + + +def _pinned_toolchain() -> str: + data = tomllib.loads( + (REPO_ROOT / "rust-toolchain.toml").read_text(encoding="utf-8") + ) + channel = data.get("toolchain", {}).get("channel") + assert channel == RUSTC_RELEASE_PIN + return channel + + +def _require_build_tools() -> None: + for tool in ("uv", "cargo", "zig", "maturin", "rustup"): + if shutil.which(tool) is None: + pytest.skip(f"{tool} is not installed") + result = subprocess.run( + ("rustup", "which", "--toolchain", _pinned_toolchain(), "rustc"), + text=True, + capture_output=True, + timeout=30, + ) + if result.returncode != 0: + pytest.skip(f"pinned Rust toolchain {_pinned_toolchain()} is not installed") + + +def _mutated_sdist( + tmp_path: Path, + source: Path, + name: str, + mutate: Callable[[Path], None], +) -> Path: + extracted = tmp_path / "mutations" / name / "extracted" + archive_root = _extract_sdist(source, extracted) + root = extracted / archive_root + mutate(root) + target = tmp_path / "mutations" / name / f"{source.stem}.{name}.tar.gz" + _write_sdist(root, target) + return target + + +def _extract_sdist(source: Path, destination: Path) -> str: + destination.mkdir(parents=True) + with tarfile.open(source, "r:gz") as archive: + members = archive.getmembers() + roots = {Path(member.name).parts[0] for member in members if member.name} + assert len(roots) == 1 + for member in members: + parts = Path(member.name).parts + assert parts and not Path(member.name).is_absolute() and ".." not in parts + archive.extractall(destination, filter="data") + return next(iter(roots)) + + +def _write_sdist(root: Path, target: Path) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("wb") as raw: + with gzip.GzipFile( + filename="", mode="wb", fileobj=raw, compresslevel=9, mtime=0 + ) as gz: + with tarfile.open( + fileobj=gz, mode="w", format=tarfile.PAX_FORMAT + ) as archive: + for path in sorted(root.rglob("*")): + relative = path.relative_to(root.parent).as_posix() + member = tarfile.TarInfo(relative) + member.mtime = 0 + if path.is_dir(): + member.type = tarfile.DIRTYPE + member.mode = 0o755 + archive.addfile(member) + elif path.is_file(): + data = path.read_bytes() + member.mode = 0o644 + member.size = len(data) + archive.addfile(member, BytesIO(data)) + + +def _remove_asset(root: Path, asset: CoreCompileInputAsset) -> None: + (root / asset.sdist_path).unlink() + + +def _move_asset_to_wrong_path(root: Path, asset: CoreCompileInputAsset) -> None: + correct = root / asset.sdist_path + wrong = correct.with_name(f"wrong-{correct.name}") + correct.rename(wrong) + + +def _redirect_include_outside_root(root: Path, asset: CoreCompileInputAsset) -> None: + source = root / asset.source_file.relative_to(_asset_source_root(asset)) + text = source.read_text(encoding="utf-8") + replacement = f'"{ESCAPED_INCLUDE_ARGUMENT}"' + assert asset.raw_argument in text + source.write_text(text.replace(asset.raw_argument, replacement), encoding="utf-8") + + +def _assert_missing_include_failure( + result: _BuildResult, asset: CoreCompileInputAsset +) -> None: + assert result.returncode != 0, result.combined_output + expected_path = _diagnostic_include_path(asset) + assert "error: couldn't read" in result.combined_output + assert expected_path in result.combined_output + assert ( + f"{asset.source_file.name}:{asset.line}:{asset.column}" + in result.combined_output + ) + + +def _assert_redirected_include_failure( + result: _BuildResult, asset: CoreCompileInputAsset +) -> None: + assert result.returncode != 0, result.combined_output + expected_path = ( + asset.source_file.relative_to(_asset_source_root(asset) / "core").parent + / ESCAPED_INCLUDE_ARGUMENT + ).as_posix() + assert "error: couldn't read" in result.combined_output + assert expected_path in result.combined_output + assert ( + f"{asset.source_file.name}:{asset.line}:{asset.column}" + in result.combined_output + ) + + +def _diagnostic_include_path(asset: CoreCompileInputAsset) -> str: + raw_value = asset.raw_argument.strip()[1:-1] + source_root = _asset_source_root(asset) + core_relative_source_parent = asset.source_file.relative_to( + source_root / "core" + ).parent + return (core_relative_source_parent / raw_value).as_posix() + + +def _asset_source_root(asset: CoreCompileInputAsset) -> Path: + for parent in asset.resolved_path.parents: + if parent / asset.sdist_path == asset.resolved_path: + return parent + raise AssertionError(f"could not derive source root for {asset.sdist_path}") diff --git a/tests/test_normalize_maturin_sdist.py b/tests/test_normalize_maturin_sdist.py index f50d081ac..b3ff515e6 100644 --- a/tests/test_normalize_maturin_sdist.py +++ b/tests/test_normalize_maturin_sdist.py @@ -40,6 +40,9 @@ def _source_workspace(root: Path) -> None: f'[package]\nname = "{Path(member).name}"\nversion = "1.2.3"\n', encoding="utf-8", ) + main = root / "core" / "crates" / "solstone-core" / "src" / "main.rs" + main.parent.mkdir(parents=True) + main.write_text("fn main() {}\n", encoding="utf-8") def _archive(root: Path, *, pruned_source: bool = False) -> Path: @@ -165,3 +168,29 @@ def test_normalizer_injects_native_sol_sources(tmp_path: Path) -> None: digest = hashlib.sha256(archive.read_bytes()).hexdigest() assert normalize_core_sdist_workspace_lock(tmp_path, archive) == () assert hashlib.sha256(archive.read_bytes()).hexdigest() == digest + + +def test_normalizer_injects_derived_compile_inputs(tmp_path: Path) -> None: + _source_workspace(tmp_path) + main = tmp_path / "core" / "crates" / "solstone-core" / "src" / "main.rs" + main.write_text( + 'const CONTRACT: &str = include_str!("../../../fixtures/sample-contract.json");\n' + "fn main() {}\n", + encoding="utf-8", + ) + asset = tmp_path / "core" / "fixtures" / "sample-contract.json" + asset.parent.mkdir(parents=True) + asset.write_text('{"ok":true}\n', encoding="utf-8") + archive = _archive(tmp_path) + + normalize_core_sdist_workspace_lock(tmp_path, archive) + + after = _members(archive) + assert ( + after["solstone_core-1.2.3/core/fixtures/sample-contract.json"] + == b'{"ok":true}\n' + ) + + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + assert normalize_core_sdist_workspace_lock(tmp_path, archive) == () + assert hashlib.sha256(archive.read_bytes()).hexdigest() == digest diff --git a/tests/test_release_candidate_driver.py b/tests/test_release_candidate_driver.py index 79a4243fa..1afaaad62 100644 --- a/tests/test_release_candidate_driver.py +++ b/tests/test_release_candidate_driver.py @@ -104,6 +104,14 @@ def _write_fake_core_sdist(root: Path, archive: Path) -> None: ) (root / "core" / "crates" / "solstone-core").mkdir(parents=True, exist_ok=True) (root / "core" / "crates" / "solstone-core-sol").mkdir(parents=True, exist_ok=True) + (root / "core" / "crates" / "solstone-core" / "src").mkdir( + parents=True, + exist_ok=True, + ) + (root / "core" / "crates" / "solstone-core-sol" / "src").mkdir( + parents=True, + exist_ok=True, + ) (root / "core" / "Cargo.toml").write_text(source_manifest, encoding="utf-8") (root / "core" / "crates" / "solstone-core" / "Cargo.toml").write_text( f'[package]\nname = "solstone-core"\nversion = "{version}"\n', @@ -113,6 +121,14 @@ def _write_fake_core_sdist(root: Path, archive: Path) -> None: f'[package]\nname = "solstone-core-sol"\nversion = "{version}"\n', encoding="utf-8", ) + (root / "core" / "crates" / "solstone-core" / "src" / "main.rs").write_text( + "fn main() {}\n", + encoding="utf-8", + ) + (root / "core" / "crates" / "solstone-core-sol" / "src" / "lib.rs").write_text( + "", + encoding="utf-8", + ) sdist_manifest = ( '[workspace]\nmembers = ["crates/solstone-core", "crates/solstone-core-sol"]\n' 'resolver = "3"\n'