diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9c88b2307..3815fd2f8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -31,6 +31,9 @@ updates: interval: "weekly" cooldown: default-days: 7 + # vcpkg updates are atomic baseline snapshots; do not delay them. + exclude: + - "*" open-pull-requests-limit: 10 groups: dependencies: diff --git a/Justfile b/Justfile index f701de32b..0fbb4bd55 100644 --- a/Justfile +++ b/Justfile @@ -277,6 +277,7 @@ python-entrypoint-test: _sync-python-dev uv run --no-sync cdt-optimize-initialize --help >/dev/null uv run --no-sync cdt-mnist-experiment --help >/dev/null uv run --no-sync cdt-tag-release --help >/dev/null + uv run --no-sync python scripts/sync_vcpkg_tool_pins.py --help >/dev/null # Synchronize the lightweight Python development environment from the lockfile. [group('workflows')] @@ -306,6 +307,11 @@ update-actions: just _action-lint just _zizmor +# Synchronize the trusted vcpkg tool release and Windows hashes with the manifest baseline. +[group('workflows')] +sync-vcpkg-tool-pins: _sync-python-dev + uv run --no-sync python scripts/sync_vcpkg_tool_pins.py + [default] [private] default: diff --git a/README.md b/README.md index f38ef6f8c..90f30b6af 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ just release-check # Validate release metadata and citation fields just changelog-unreleased vX.Y.Z # Generate a pending release changelog just tag-check vX.Y.Z # Preview and validate an annotated release tag just update-actions # Update and repin Actions with pinact, then validate +just sync-vcpkg-tool-pins # Sync the vcpkg tool release and Windows hashes just python-sync # Install the locked Python development environment just python-check # Check Python formatting, lint, and types just python-fix # Apply safe Ruff fixes and formatting @@ -344,21 +345,28 @@ actionlint uses its pinned upstream version, and zizmor uses its pinned PyPI whe The native build entry points delegate checkout provenance, baseline, and executable-integrity validation directly to `scripts/bootstrap_vcpkg.py`, whose cross-platform fixtures run under `just check`. -To update dependencies intentionally, bootstrap the current checkout, run the vcpkg baseline updater, review the -manifest diff, and then rerun the complete build: +To update dependencies intentionally, bootstrap the current checkout, run the vcpkg baseline updater, synchronize +the independently reviewed tool pins, review both diffs, and then rerun the complete build: ```bash python3 scripts/bootstrap_vcpkg.py export VCPKG_ROOT="$PWD/.cache/vcpkg" "$VCPKG_ROOT/vcpkg" x-update-baseline +just sync-vcpkg-tool-pins ./scripts/build.sh ``` -On Windows, invoke the same implementation with `python.exe scripts\bootstrap_vcpkg.py`; `scripts\build.bat` and -`scripts\fast-build.bat` already do this directly. +`just sync-vcpkg-tool-pins` reads the new manifest baseline, fetches that exact upstream commit's tool metadata, +downloads the official Windows amd64 and arm64 release assets, and atomically updates the release and SHA-256 pins in +`scripts/bootstrap_vcpkg.py`. It leaves the existing pins unchanged if any input cannot be fetched or validated, and +only writes when the rendered release and hash assignments differ from the bootstrap source. + +On Windows, invoke the synchronizer with `python.exe scripts\sync_vcpkg_tool_pins.py`; `scripts\build.bat` and +`scripts\fast-build.bat` already invoke the bootstrap implementation directly. CI uses `lukka/run-vcpkg`, which derives the vcpkg checkout commit from the same manifest baseline and supplies a -binary cache. No separately maintained repository variable is required. +binary cache. No separately maintained checkout SHA is required; the bootstrap script retains an independent tool +release and Windows executable hashes as a supply-chain review gate. CodeQL keeps third-party implementation findings out of CDT++ results through a two-phase manual build. `just codeql-prepare` configures the project, installs manifest dependencies before CodeQL starts tracing, and uses a diff --git a/docs/reproducibility.md b/docs/reproducibility.md index 6e523be61..4a188cd36 100644 --- a/docs/reproducibility.md +++ b/docs/reproducibility.md @@ -90,16 +90,18 @@ manifest. The manifest records: - a canonical placement fingerprint derived from sorted finite vertices and their timeslices; - a canonical topology fingerprint derived from sorted vertices, causal - metadata, and finite cells; + metadata, and finite-cell incidence; - the CDT++ version, compiler, build configuration, standard library, operating system, architecture, C++ standard, and CGAL version; - the payload byte count and FNV-1a corruption checksum. The triangulation remains a CGAL-readable payload; provenance is in the sidecar rather than prepended to the CGAL stream. Because CGAL's native triangulation -stream omits `info()` fields, CDT++ appends a versioned, canonically ordered +stream omits `info()` fields, CDT++ appends a versioned, payload-indexed causal-data trailer that preserves every finite vertex timeslice and cell type. -Legacy streams without this trailer remain topology-readable. Before +Payloads using the older `cdt-plusplus-causal-info-v1` trailer are rejected +entirely by `read_causal_info` rather than loaded with their causal metadata +discarded. Legacy streams without a trailer remain topology-readable. Before publication, CDT++ serializes with round-trip floating-point precision to a temporary file, flushes and closes it, parses the complete CGAL stream and causal trailer, rejects any other trailing data, validates its triangulation diff --git a/include/Utilities.hpp b/include/Utilities.hpp index 0a66cd6d8..b2c64a366 100644 --- a/include/Utilities.hpp +++ b/include/Utilities.hpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include // H. Hinnant date and time library @@ -145,7 +146,7 @@ namespace cdt::utilities namespace detail { inline constexpr std::string_view CAUSAL_INFO_HEADER{ - "cdt-plusplus-causal-info-v1"}; + "cdt-plusplus-causal-info-v2"}; struct Payload_integrity { @@ -221,7 +222,7 @@ namespace cdt::utilities CGAL::to_double(point.z())); } - [[nodiscard]] inline auto cell_key(auto const& cell) -> std::string + [[nodiscard]] inline auto point_cell_key(auto const& cell) -> std::string { std::array points{point_key(cell->vertex(0)->point()), point_key(cell->vertex(1)->point()), @@ -232,6 +233,184 @@ namespace cdt::utilities points[3]); } + template + [[nodiscard]] auto canonical_colors( + std::vector const& signatures) -> std::vector + { + auto ordered = signatures; + std::ranges::sort(ordered); + ordered.erase(std::unique(ordered.begin(), ordered.end()), ordered.end()); + + std::vector colors; + colors.reserve(signatures.size()); + for (auto const& signature : signatures) + { + colors.push_back(static_cast( + std::lower_bound(ordered.begin(), ordered.end(), signature) - + ordered.begin())); + } + return colors; + } + + inline constexpr std::size_t CANONICAL_INCIDENCE_WORK_BUDGET{100'000}; + + inline void consume_canonical_incidence_work(std::size_t& budget) + { + if (budget == 0) + { + throw std::runtime_error{ + "Canonical incidence search exceeded its work budget"}; + } + --budget; + } + + [[nodiscard]] inline auto refine_incidence_colors( + std::vector> const& adjacency, + std::vector colors, std::size_t& budget) + -> std::vector + { + for (std::size_t iteration = 0; iteration < adjacency.size(); ++iteration) + { + consume_canonical_incidence_work(budget); + std::vector> signatures; + signatures.reserve(adjacency.size()); + for (std::size_t node = 0; node < adjacency.size(); ++node) + { + std::vector neighboring_colors; + neighboring_colors.reserve(adjacency[node].size()); + for (auto const neighbor : adjacency[node]) + { + neighboring_colors.push_back(colors.at(neighbor)); + } + std::ranges::sort(neighboring_colors); + + std::vector signature; + signature.reserve(neighboring_colors.size() + 1); + signature.push_back(colors[node]); + signature.insert(signature.end(), neighboring_colors.begin(), + neighboring_colors.end()); + signatures.push_back(std::move(signature)); + } + + auto refined = canonical_colors(signatures); + if (refined == colors) { break; } + colors = std::move(refined); + } + return colors; + } + + /// @pre `colors` is a permutation of `[0, colors.size())`, assigning every + /// node a unique, valid color index. + [[nodiscard]] inline auto incidence_records_for_coloring( + std::vector const& bases, + std::vector> const& adjacency, + std::vector const& colors) -> std::vector + { + std::vector nodes_by_color(colors.size()); + for (std::size_t node = 0; node < colors.size(); ++node) + { + nodes_by_color.at(colors[node]) = node; + } + + std::vector records; + records.reserve(bases.size()); + for (auto const node : nodes_by_color) + { + std::vector neighboring_colors; + neighboring_colors.reserve(adjacency[node].size()); + for (auto const neighbor : adjacency[node]) + { + neighboring_colors.push_back(colors.at(neighbor)); + } + std::ranges::sort(neighboring_colors); + + auto record = + fmt::format("{}:{}:neighbors=", bases[node].size(), bases[node]); + for (auto const color : neighboring_colors) + { + record.append(std::to_string(color)); + record.push_back(';'); + } + records.push_back(std::move(record)); + } + return records; + } + + [[nodiscard]] inline auto canonical_incidence_search( + std::vector const& bases, + std::vector> const& adjacency, + std::vector colors, std::size_t& budget) + -> std::vector + { + consume_canonical_incidence_work(budget); + colors = refine_incidence_colors(adjacency, std::move(colors), budget); + + std::vector color_counts(colors.size()); + for (auto const color : colors) { ++color_counts.at(color); } + auto const ambiguous = std::ranges::find_if( + color_counts, [](std::size_t const count) { return count > 1; }); + if (ambiguous == color_counts.end()) + { + return incidence_records_for_coloring(bases, adjacency, colors); + } + + auto const ambiguous_color = + static_cast(ambiguous - color_counts.begin()); + auto const individualized_color = *std::ranges::max_element(colors) + 1; + std::optional> best; + for (std::size_t node = 0; node < colors.size(); ++node) + { + if (colors[node] != ambiguous_color) { continue; } + auto individualized = colors; + individualized[node] = individualized_color; + auto candidate = canonical_incidence_search( + bases, adjacency, std::move(individualized), budget); + if (!best || candidate < *best) { best = std::move(candidate); } + } + return std::move(*best); + } + + [[nodiscard]] inline auto canonical_bipartite_incidence_records( + std::vector const& vertex_bases, + std::vector const& cell_bases, + std::vector> const& cell_vertices) + -> std::vector + { + if (cell_bases.size() != cell_vertices.size()) + { + throw std::invalid_argument{ + "Cell bases and incidence records must have equal sizes"}; + } + for (auto const& incident_vertices : cell_vertices) + { + for (auto const vertex : incident_vertices) + { + if (vertex >= vertex_bases.size()) + { + throw std::invalid_argument{ + "Cell incidence records must reference known vertices"}; + } + } + } + + auto bases = vertex_bases; + bases.insert(bases.end(), cell_bases.begin(), cell_bases.end()); + if (bases.empty()) { return {}; } + std::vector> adjacency(bases.size()); + for (std::size_t cell = 0; cell < cell_vertices.size(); ++cell) + { + auto const cell_node = vertex_bases.size() + cell; + for (auto const vertex : cell_vertices[cell]) + { + adjacency.at(vertex).push_back(cell_node); + adjacency[cell_node].push_back(vertex); + } + } + auto budget = CANONICAL_INCIDENCE_WORK_BUDGET; + return canonical_incidence_search(bases, adjacency, + canonical_colors(bases), budget); + } + template inline constexpr bool HAS_CAUSAL_INFO = requires(TriangulationType const& triangulation) { @@ -257,6 +436,61 @@ namespace cdt::utilities return records; } + template + [[nodiscard]] auto has_coincident_vertices( + TriangulationType const& triangulation) -> bool + { + std::map point_counts; + for (auto const vertex : triangulation.finite_vertex_handles()) + { + if (++point_counts[point_key(vertex->point())] > 1) { return true; } + } + return false; + } + + template + [[nodiscard]] auto incidence_topology_records( + TriangulationType const& triangulation) -> std::vector + { + auto const finite_vertices = triangulation.finite_vertex_handles(); + using Vertex_handle = + std::remove_cvref_t; + std::vector vertices(finite_vertices.begin(), + finite_vertices.end()); + + auto const finite_cells = triangulation.finite_cell_handles(); + using Cell_handle = std::remove_cvref_t; + std::vector cells(finite_cells.begin(), finite_cells.end()); + + std::map vertex_indices; + std::vector vertex_bases; + vertex_bases.reserve(vertices.size()); + for (std::size_t index = 0; index < vertices.size(); ++index) + { + vertex_indices.emplace(vertices[index], index); + vertex_bases.emplace_back( + fmt::format("v:{}:{}", point_key(vertices[index]->point()), + vertices[index]->info())); + } + + std::vector cell_bases; + cell_bases.reserve(cells.size()); + std::vector> cell_vertices(cells.size()); + for (std::size_t cell_index = 0; cell_index < cells.size(); ++cell_index) + { + cell_bases.emplace_back(fmt::format("c:{}", cells[cell_index]->info())); + cell_vertices[cell_index].reserve(4); + for (std::size_t local_index = 0; local_index < 4; ++local_index) + { + auto const vertex_index = vertex_indices.at( + cells[cell_index]->vertex(static_cast(local_index))); + cell_vertices[cell_index].push_back(vertex_index); + } + } + return canonical_bipartite_incidence_records(vertex_bases, cell_bases, + cell_vertices); + } + [[nodiscard]] inline auto fingerprint_records( std::vector const& records) -> std::uint64_t { @@ -283,6 +517,13 @@ namespace cdt::utilities [[nodiscard]] auto canonical_topology_fingerprint( TriangulationType const& triangulation) -> std::uint64_t { + // topology.fnv1a64 uses different record schemes in these branches, so + // coincident-coordinate and point-keyed digests are not comparable. + if (has_coincident_vertices(triangulation)) + { + return fingerprint_records(incidence_topology_records(triangulation)); + } + auto records = vertex_records(triangulation); records.reserve( static_cast(triangulation.number_of_vertices() + @@ -290,7 +531,7 @@ namespace cdt::utilities for (auto const cell : triangulation.finite_cell_handles()) { records.emplace_back( - fmt::format("c:{}:{}", cell_key(cell), cell->info())); + fmt::format("c:{}:{}", point_cell_key(cell), cell->info())); } std::ranges::sort(records); return fingerprint_records(records); @@ -302,25 +543,29 @@ namespace cdt::utilities { if constexpr (HAS_CAUSAL_INFO) { + // CGAL writes and recreates vertices and cells in container order. + // Persist those payload indices because distinct TDS vertices may be + // geometrically coincident after topological moves. std::vector vertices; vertices.reserve( static_cast(triangulation.number_of_vertices())); + std::uint64_t vertex_index{}; for (auto const vertex : triangulation.finite_vertex_handles()) { vertices.emplace_back( - fmt::format("{}|{}", point_key(vertex->point()), vertex->info())); + fmt::format("{}|{}", vertex_index, vertex->info())); + ++vertex_index; } - std::ranges::sort(vertices); std::vector cells; cells.reserve( static_cast(triangulation.number_of_finite_cells())); + std::uint64_t cell_index{}; for (auto const cell : triangulation.finite_cell_handles()) { - cells.emplace_back( - fmt::format("{}|{}", cell_key(cell), cell->info())); + cells.emplace_back(fmt::format("{}|{}", cell_index, cell->info())); + ++cell_index; } - std::ranges::sort(cells); output << '\n' << CAUSAL_INFO_HEADER << '\n'; output << "vertices=" << vertices.size() << '\n'; @@ -525,6 +770,48 @@ namespace cdt::utilities path); } + [[nodiscard]] inline auto read_indexed_info( + std::istream& input, std::string_view const prefix, + std::uint64_t const count, std::filesystem::path const& path) + -> std::vector + { + std::vector> indexed( + static_cast(count)); + std::string line; + for (std::uint64_t record_index = 0; record_index < count; ++record_index) + { + if (!std::getline(input, line)) + { + throw std::filesystem::filesystem_error( + "Truncated causal triangulation metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + auto const [key, value] = parse_record(line, prefix, path); + auto const index = parse_unsigned(key, 10, path); + if (index >= count || indexed[static_cast(index)]) + { + throw std::filesystem::filesystem_error( + "Duplicate or out-of-range causal metadata index", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + indexed[static_cast(index)] = value; + } + + std::vector values; + values.reserve(indexed.size()); + for (auto const& value : indexed) + { + if (!value) + { + throw std::filesystem::filesystem_error( + "Missing causal metadata index", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + values.push_back(*value); + } + return values; + } + template void read_causal_info(std::istream& input, TriangulationType& triangulation, std::filesystem::path const& path) @@ -552,23 +839,8 @@ namespace cdt::utilities std::make_error_code(std::errc::illegal_byte_sequence)); } - std::map vertex_info; - for (std::uint64_t index = 0; index < vertex_count; ++index) - { - if (!std::getline(input, line)) - { - throw std::filesystem::filesystem_error( - "Truncated causal vertex metadata", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - auto [key, value] = parse_record(line, "v=", path); - if (!vertex_info.emplace(std::move(key), value).second) - { - throw std::filesystem::filesystem_error( - "Duplicate causal vertex metadata", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - } + auto const vertex_info = + read_indexed_info(input, "v=", vertex_count, path); if (!std::getline(input, line)) { @@ -585,53 +857,19 @@ namespace cdt::utilities std::make_error_code(std::errc::illegal_byte_sequence)); } - std::map cell_info; - for (std::uint64_t index = 0; index < cell_count; ++index) - { - if (!std::getline(input, line)) - { - throw std::filesystem::filesystem_error( - "Truncated causal cell metadata", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - auto [key, value] = parse_record(line, "c=", path); - if (!cell_info.emplace(std::move(key), value).second) - { - throw std::filesystem::filesystem_error( - "Duplicate causal cell metadata", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - } + auto const cell_info = read_indexed_info(input, "c=", cell_count, path); + std::size_t vertex_index{}; for (auto const vertex : triangulation.finite_vertex_handles()) { - auto const found = vertex_info.find(point_key(vertex->point())); - if (found == vertex_info.end()) - { - throw std::filesystem::filesystem_error( - "Causal vertex metadata does not match triangulation", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - vertex->info() = found->second; - vertex_info.erase(found); + vertex->info() = vertex_info.at(vertex_index); + ++vertex_index; } + std::size_t cell_index{}; for (auto const cell : triangulation.finite_cell_handles()) { - auto const found = cell_info.find(cell_key(cell)); - if (found == cell_info.end()) - { - throw std::filesystem::filesystem_error( - "Causal cell metadata does not match triangulation", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - cell->info() = found->second; - cell_info.erase(found); - } - if (!vertex_info.empty() || !cell_info.empty()) - { - throw std::filesystem::filesystem_error( - "Causal metadata contains records outside the triangulation", path, - std::make_error_code(std::errc::illegal_byte_sequence)); + cell->info() = cell_info.at(cell_index); + ++cell_index; } } diff --git a/scripts/sync_vcpkg_tool_pins.py b/scripts/sync_vcpkg_tool_pins.py new file mode 100644 index 000000000..f6312e1f4 --- /dev/null +++ b/scripts/sync_vcpkg_tool_pins.py @@ -0,0 +1,205 @@ +"""Synchronize trusted vcpkg tool pins with the manifest's exact baseline.""" + +from __future__ import annotations + +import argparse +import ast +import hashlib +import json +import os +import re +import stat +import sys +import tempfile +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + +METADATA_URL = "https://raw.githubusercontent.com/microsoft/vcpkg/{baseline}/scripts/vcpkg-tool-metadata.txt" +WINDOWS_TOOL_URLS = { + "amd64": "https://github.com/microsoft/vcpkg-tool/releases/download/{release}/vcpkg.exe", + "arm64": "https://github.com/microsoft/vcpkg-tool/releases/download/{release}/vcpkg-arm64.exe", +} + + +class PinSyncError(RuntimeError): + """Report an unusable baseline, metadata file, or tool release.""" + + +@dataclass(frozen=True) +class ToolPins: + """Describe the trusted Windows assets for one vcpkg tool release.""" + + release: str + windows_sha256: dict[str, str] + + +def _download(url: str) -> bytes: + """Download one official vcpkg resource.""" + request = urllib.request.Request( # noqa: S310 - callers use fixed official HTTPS URL templates. + url, + headers={"User-Agent": "CDT-plusplus-vcpkg-pin-sync"}, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - URLs are fixed to official HTTPS hosts. + return response.read() + except (OSError, urllib.error.URLError) as error: + message = f"Unable to download {url}: {error}" + raise PinSyncError(message) from error + + +def _read_baseline(manifest: Path) -> str: + """Read the exact vcpkg registry baseline from the project manifest.""" + try: + document = json.loads(manifest.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + message = f"Unable to read {manifest}: {error}" + raise PinSyncError(message) from error + baseline = document.get("builtin-baseline") if isinstance(document, dict) else None + if not isinstance(baseline, str) or re.fullmatch(r"[0-9a-f]{40}", baseline) is None: + message = f"Unable to read a 40-character builtin-baseline from {manifest}." + raise PinSyncError(message) + return baseline + + +def _read_tool_release(metadata: bytes, source: str) -> str: + """Extract and validate the vcpkg tool release tag from upstream metadata.""" + try: + lines = metadata.decode("utf-8").splitlines() + except UnicodeDecodeError as error: + message = f"Unable to decode vcpkg tool metadata from {source}." + raise PinSyncError(message) from error + values = dict(line.partition("=")[::2] for line in lines if "=" in line) + release = values.get("VCPKG_TOOL_RELEASE_TAG", "") + if re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", release) is None: + message = f"Invalid VCPKG_TOOL_RELEASE_TAG in {source}." + raise PinSyncError(message) + return release + + +def _collect_pins(baseline: str, download: Callable[[str], bytes]) -> ToolPins: + """Download all inputs and calculate pins without modifying the repository.""" + metadata_url = METADATA_URL.format(baseline=baseline) + release = _read_tool_release(download(metadata_url), metadata_url) + windows_sha256 = {architecture: hashlib.sha256(download(url.format(release=release))).hexdigest() for architecture, url in WINDOWS_TOOL_URLS.items()} + return ToolPins(release, windows_sha256) + + +def _assignment_nodes(source: str) -> dict[str, ast.Assign]: + """Locate the two bootstrap assignments that the synchronizer owns.""" + try: + tree = ast.parse(source) + except SyntaxError as error: + message = f"Unable to parse scripts/bootstrap_vcpkg.py: {error}" + raise PinSyncError(message) from error + wanted = {"VCPKG_TOOL_RELEASE", "WINDOWS_TOOL_SHA256"} + found: dict[str, ast.Assign] = {} + for node in tree.body: + if not isinstance(node, ast.Assign) or len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name): + continue + name = node.targets[0].id + if name in wanted: + if name in found: + message = f"scripts/bootstrap_vcpkg.py defines {name} more than once." + raise PinSyncError(message) + found[name] = node + missing = wanted - found.keys() + if missing: + message = f"scripts/bootstrap_vcpkg.py is missing pin assignment(s): {', '.join(sorted(missing))}." + raise PinSyncError(message) + return found + + +def _render_bootstrap(source: str, pins: ToolPins) -> str: + """Render both trusted pin assignments while preserving unrelated source.""" + line_ending_match = re.search(r"\r\n|\n|\r", source) + line_ending = line_ending_match.group(0) if line_ending_match is not None else "\n" + replacements = { + "VCPKG_TOOL_RELEASE": f'VCPKG_TOOL_RELEASE = "{pins.release}"{line_ending}', + "WINDOWS_TOOL_SHA256": ( + f"WINDOWS_TOOL_SHA256 = {{{line_ending}" + f' "amd64": "{pins.windows_sha256["amd64"]}",{line_ending}' + f' "arm64": "{pins.windows_sha256["arm64"]}",{line_ending}' + f"}}{line_ending}" + ), + } + lines = source.splitlines(keepends=True) + assignments = _assignment_nodes(source) + for name, node in sorted(assignments.items(), key=lambda item: item[1].lineno, reverse=True): + if node.end_lineno is None: + message = f"Unable to locate the end of {name} in scripts/bootstrap_vcpkg.py." + raise PinSyncError(message) + lines[node.lineno - 1 : node.end_lineno] = [replacements[name]] + return "".join(lines) + + +def _atomic_write(path: Path, content: str) -> None: + """Replace one source file atomically while preserving its permissions.""" + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + newline="", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + temporary.write(content) + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path.chmod(stat.S_IMODE(path.stat().st_mode)) + temporary_path.replace(path) + except OSError as error: + message = f"Unable to update {path}: {error}" + raise PinSyncError(message) from error + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + +def sync_vcpkg_tool_pins(repository_root: Path, *, download: Callable[[str], bytes] = _download) -> ToolPins: + """Synchronize the bootstrap tool release and hashes for the manifest baseline.""" + baseline = _read_baseline(repository_root / "vcpkg.json") + pins = _collect_pins(baseline, download) + bootstrap = repository_root / "scripts" / "bootstrap_vcpkg.py" + try: + with bootstrap.open("r", encoding="utf-8", newline="") as source: + original = source.read() + except OSError as error: + message = f"Unable to read {bootstrap}: {error}" + raise PinSyncError(message) from error + updated = _render_bootstrap(original, pins) + if updated != original: + _atomic_write(bootstrap, updated) + return pins + + +def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: + """Parse the intentionally argument-free synchronization command.""" + parser = argparse.ArgumentParser(description=__doc__) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Synchronize pins for the repository containing this script.""" + _parse_args(argv) + repository_root = Path(__file__).resolve().parent.parent + try: + pins = sync_vcpkg_tool_pins(repository_root) + except PinSyncError as error: + print(error, file=sys.stderr) + return 1 + print(f"Pinned vcpkg tool {pins.release} for Windows amd64 and arm64.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_sync_vcpkg_tool_pins.py b/scripts/tests/test_sync_vcpkg_tool_pins.py new file mode 100644 index 000000000..f305d6d76 --- /dev/null +++ b/scripts/tests/test_sync_vcpkg_tool_pins.py @@ -0,0 +1,138 @@ +"""Tests for atomic vcpkg tool-pin synchronization.""" + +from __future__ import annotations + +import hashlib +import tempfile +import unittest +from pathlib import Path + +from scripts import sync_vcpkg_tool_pins + + +class SyncVcpkgToolPinsTests(unittest.TestCase): + """Exercise exact-baseline fetching and failure-safe source updates.""" + + baseline = "1" * 40 + release = "2026-07-27" + original_bootstrap = ( + f'VCPKG_TOOL_RELEASE = "2026-07-13"\nUNCHANGED = "preserve me"\nWINDOWS_TOOL_SHA256 = {{\n "amd64": "{"2" * 64}",\n "arm64": "{"3" * 64}",\n}}\n' + ) + + def _make_repository(self, root: Path) -> Path: + """Create the manifest and bootstrap source owned by the synchronizer.""" + (root / "vcpkg.json").write_text(f'{{"builtin-baseline": "{self.baseline}"}}\n', encoding="utf-8") + scripts = root / "scripts" + scripts.mkdir() + bootstrap = scripts / "bootstrap_vcpkg.py" + bootstrap.write_text(self.original_bootstrap, encoding="utf-8") + return bootstrap + + def test_syncs_release_and_both_hashes_from_exact_baseline(self) -> None: + """The manifest commit selects metadata and both official Windows assets.""" + amd64 = b"amd64 executable" + arm64 = b"arm64 executable" + requested: list[str] = [] + + def download(url: str) -> bytes: + requested.append(url) + if url == sync_vcpkg_tool_pins.METADATA_URL.format(baseline=self.baseline): + return f"VCPKG_TOOL_RELEASE_TAG={self.release}\n".encode() + if url.endswith("/vcpkg-arm64.exe"): + return arm64 + if url.endswith("/vcpkg.exe"): + return amd64 + message = f"Unexpected URL: {url}" + raise AssertionError(message) + + with tempfile.TemporaryDirectory() as temp_dir: + repository_root = Path(temp_dir) + bootstrap = self._make_repository(repository_root) + + pins = sync_vcpkg_tool_pins.sync_vcpkg_tool_pins(repository_root, download=download) + + updated = bootstrap.read_text(encoding="utf-8") + + self.assertEqual(pins.release, self.release) + self.assertEqual( + requested, + [ + sync_vcpkg_tool_pins.METADATA_URL.format(baseline=self.baseline), + sync_vcpkg_tool_pins.WINDOWS_TOOL_URLS["amd64"].format(release=self.release), + sync_vcpkg_tool_pins.WINDOWS_TOOL_URLS["arm64"].format(release=self.release), + ], + ) + self.assertIn(f'VCPKG_TOOL_RELEASE = "{self.release}"', updated) + self.assertIn(hashlib.sha256(amd64).hexdigest(), updated) + self.assertIn(hashlib.sha256(arm64).hexdigest(), updated) + self.assertIn('UNCHANGED = "preserve me"', updated) + + def test_download_failure_preserves_existing_pins(self) -> None: + """A partial asset download cannot publish a partial source update.""" + calls = 0 + + def download(_url: str) -> bytes: + nonlocal calls + calls += 1 + if calls == 1: + return f"VCPKG_TOOL_RELEASE_TAG={self.release}\n".encode() + if calls == 2: + return b"amd64 executable" + message = "simulated arm64 download failure" + raise sync_vcpkg_tool_pins.PinSyncError(message) + + with tempfile.TemporaryDirectory() as temp_dir: + repository_root = Path(temp_dir) + bootstrap = self._make_repository(repository_root) + + with self.assertRaisesRegex(sync_vcpkg_tool_pins.PinSyncError, "arm64 download failure"): + sync_vcpkg_tool_pins.sync_vcpkg_tool_pins(repository_root, download=download) + + self.assertEqual(bootstrap.read_text(encoding="utf-8"), self.original_bootstrap) + + def test_sync_preserves_crlf_and_unrelated_bytes(self) -> None: + """A CRLF bootstrap retains its line endings and unrelated source.""" + amd64 = b"amd64 executable" + arm64 = b"arm64 executable" + + def download(url: str) -> bytes: + if url == sync_vcpkg_tool_pins.METADATA_URL.format(baseline=self.baseline): + return f"VCPKG_TOOL_RELEASE_TAG={self.release}\n".encode() + return arm64 if url.endswith("/vcpkg-arm64.exe") else amd64 + + original = self.original_bootstrap.replace("\n", "\r\n").encode() + expected = ( + f'VCPKG_TOOL_RELEASE = "{self.release}"\r\n' + 'UNCHANGED = "preserve me"\r\n' + "WINDOWS_TOOL_SHA256 = {\r\n" + f' "amd64": "{hashlib.sha256(amd64).hexdigest()}",\r\n' + f' "arm64": "{hashlib.sha256(arm64).hexdigest()}",\r\n' + "}\r\n" + ).encode() + + with tempfile.TemporaryDirectory() as temp_dir: + repository_root = Path(temp_dir) + bootstrap = self._make_repository(repository_root) + bootstrap.write_bytes(original) + + sync_vcpkg_tool_pins.sync_vcpkg_tool_pins(repository_root, download=download) + + self.assertEqual(bootstrap.read_bytes(), expected) + + def test_invalid_metadata_preserves_existing_pins(self) -> None: + """Malformed upstream metadata is rejected before any source update.""" + with tempfile.TemporaryDirectory() as temp_dir: + repository_root = Path(temp_dir) + bootstrap = self._make_repository(repository_root) + + with self.assertRaisesRegex(sync_vcpkg_tool_pins.PinSyncError, "Invalid VCPKG_TOOL_RELEASE_TAG"): + sync_vcpkg_tool_pins.sync_vcpkg_tool_pins( + repository_root, + download=lambda _url: b"VCPKG_TOOL_RELEASE_TAG=not-a-release\n", + ) + + self.assertEqual(bootstrap.read_text(encoding="utf-8"), self.original_bootstrap) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/Utilities_test.cpp b/tests/Utilities_test.cpp index 6e6770f8b..5863a033a 100644 --- a/tests/Utilities_test.cpp +++ b/tests/Utilities_test.cpp @@ -255,6 +255,111 @@ SCENARIO("Printing Delaunay triangulations" * doctest::test_suite("utilities")) } } +SCENARIO("Canonical incidence records distinguish degree-equivalent graphs" * + doctest::test_suite("utilities")) +{ + GIVEN("A bipartite cycle and two components with the same node degrees") + { + std::vector const vertex_bases(4, "v:coincident:0"); + std::vector const cell_bases(4, "c:0"); + std::vector> const cycle{ + {0, 1}, + {1, 2}, + {2, 3}, + {3, 0} + }; + std::vector> const reordered_cycle{ + {0, 2}, + {1, 3}, + {1, 2}, + {0, 3} + }; + std::vector> const two_components{ + {0, 1}, + {0, 1}, + {2, 3}, + {2, 3} + }; + + WHEN("Their complete bipartite incidence is canonicalized") + { + auto const cycle_records = + utilities::detail::canonical_bipartite_incidence_records( + vertex_bases, cell_bases, cycle); + auto const reordered_records = + utilities::detail::canonical_bipartite_incidence_records( + vertex_bases, cell_bases, reordered_cycle); + auto const component_records = + utilities::detail::canonical_bipartite_incidence_records( + vertex_bases, cell_bases, two_components); + + THEN("Reordered equivalent edges produce identical records") + { CHECK(cycle_records == reordered_records); } + + THEN("A degree-equivalent connectivity change produces different records") + { CHECK(cycle_records != component_records); } + } + + WHEN("The cell bases and incidence records have unequal sizes") + { + std::vector> const mismatched_incidence{ + {0, 1} + }; + + THEN("Canonicalization rejects the inconsistent input") + { + CHECK_THROWS_AS( + static_cast( + utilities::detail::canonical_bipartite_incidence_records( + vertex_bases, cell_bases, mismatched_incidence)), + std::invalid_argument); + } + } + + WHEN("A cell incidence record references a non-vertex node") + { + auto invalid_indices = cycle; + invalid_indices.front().front() = vertex_bases.size(); + + THEN("Canonicalization rejects the invalid vertex index") + { + CHECK_THROWS_AS( + static_cast( + utilities::detail::canonical_bipartite_incidence_records( + vertex_bases, cell_bases, invalid_indices)), + std::invalid_argument); + } + } + + WHEN("Only one canonical search work unit remains") + { + std::vector const ambiguous_bases{"node", "node"}; + std::vector> const adjacency{{1}, {0}}; + std::vector const colors{0, 0}; + std::size_t budget{1}; + + THEN("Refinement reports budget exhaustion") + { + CHECK_THROWS_WITH_AS( + static_cast(utilities::detail::canonical_incidence_search( + ambiguous_bases, adjacency, colors, budget)), + "Canonical incidence search exceeded its work budget", + std::runtime_error); + } + } + + WHEN("No vertices or cells are supplied") + { + THEN("Canonicalization returns no records") + { + CHECK( + utilities::detail::canonical_bipartite_incidence_records({}, {}, {}) + .empty()); + } + } + } +} + SCENARIO("Reading and writing Delaunay triangulations to files" * doctest::test_suite("utilities")) { @@ -328,6 +433,131 @@ SCENARIO("Reading and writing Delaunay triangulations to files" * utilities::detail::canonical_topology_fingerprint(annotated)); } } + WHEN("Distinct causal vertices occupy the same geometric point") + { + TemporaryDirectory const directory; + auto const filename = directory.file("coincident.off"); + auto annotated = manifold.delaunay_snapshot(); + auto const vertices = annotated.finite_vertex_handles(); + auto first = vertices.begin(); + REQUIRE(first != vertices.end()); + auto second = std::next(first); + REQUIRE(second != vertices.end()); + (*second)->set_point((*first)->point()); + REQUIRE((*second)->point() == (*first)->point()); + + Int_precision vertex_info{10}; + for (auto const vertex : annotated.finite_vertex_handles()) + { + vertex->info() = vertex_info++; + } + Int_precision cell_info{31}; + for (auto const cell : annotated.finite_cell_handles()) + { + cell->info() = cell_info++; + } + + write_file(filename, annotated); + auto const restored = read_file>(filename); + + THEN("The topology fingerprint remains stable across the round trip") + { + CHECK_EQ(utilities::detail::canonical_topology_fingerprint(restored), + utilities::detail::canonical_topology_fingerprint(annotated)); + } + + THEN("Payload indices preserve all vertex and cell metadata") + { + auto original_vertex = annotated.finite_vertex_handles().begin(); + auto restored_vertex = restored.finite_vertex_handles().begin(); + for (; original_vertex != annotated.finite_vertex_handles().end(); + ++original_vertex, ++restored_vertex) + { + REQUIRE(restored_vertex != restored.finite_vertex_handles().end()); + CHECK_EQ((*restored_vertex)->info(), (*original_vertex)->info()); + } + CHECK(restored_vertex == restored.finite_vertex_handles().end()); + + auto original_cell = annotated.finite_cell_handles().begin(); + auto restored_cell = restored.finite_cell_handles().begin(); + for (; original_cell != annotated.finite_cell_handles().end(); + ++original_cell, ++restored_cell) + { + REQUIRE(restored_cell != restored.finite_cell_handles().end()); + CHECK_EQ((*restored_cell)->info(), (*original_cell)->info()); + } + CHECK(restored_cell == restored.finite_cell_handles().end()); + } + } + WHEN( + "Coincident vertices exchange causal labels across distinct cell stars") + { + auto triangulation_with_interior = manifold.delaunay_snapshot(); + triangulation_with_interior.insert(Point_t<3>(0.25, 0.25, 0.25)); + + Delaunay_t<3>::Cell_handle first_cell; + Delaunay_t<3>::Cell_handle second_cell; + for (auto const cell : triangulation_with_interior.finite_cell_handles()) + { + for (int index = 0; index < 4; ++index) + { + auto const neighbor = cell->neighbor(index); + if (!triangulation_with_interior.is_infinite(neighbor)) + { + first_cell = cell; + second_cell = neighbor; + break; + } + } + if (first_cell != Delaunay_t<3>::Cell_handle{}) { break; } + } + REQUIRE(first_cell != Delaunay_t<3>::Cell_handle{}); + REQUIRE(second_cell != Delaunay_t<3>::Cell_handle{}); + + auto unique_vertex = [](Delaunay_t<3>::Cell_handle const cell, + Delaunay_t<3>::Cell_handle const other) { + for (int cell_index = 0; cell_index < 4; ++cell_index) + { + auto const candidate = cell->vertex(cell_index); + bool shared{}; + for (int other_index = 0; other_index < 4; ++other_index) + { + shared = shared || candidate == other->vertex(other_index); + } + if (!shared) { return candidate; } + } + return Delaunay_t<3>::Vertex_handle{}; + }; + auto const first_vertex = unique_vertex(first_cell, second_cell); + auto const second_vertex = unique_vertex(second_cell, first_cell); + REQUIRE(first_vertex != Delaunay_t<3>::Vertex_handle{}); + REQUIRE(second_vertex != Delaunay_t<3>::Vertex_handle{}); + REQUIRE(first_vertex != second_vertex); + second_vertex->set_point(first_vertex->point()); + + Int_precision vertex_info{10}; + for (auto const vertex : + triangulation_with_interior.finite_vertex_handles()) + { + vertex->info() = vertex_info++; + } + Int_precision cell_info{31}; + for (auto const cell : triangulation_with_interior.finite_cell_handles()) + { + cell->info() = cell_info++; + } + + auto const fingerprint_before = + utilities::detail::canonical_topology_fingerprint( + triangulation_with_interior); + std::swap(first_vertex->info(), second_vertex->info()); + auto const fingerprint_after = + utilities::detail::canonical_topology_fingerprint( + triangulation_with_interior); + + THEN("the topology fingerprint detects the changed incidence") + { CHECK_NE(fingerprint_before, fingerprint_after); } + } WHEN("A stochastic artifact is written with reproducibility metadata") { TemporaryDirectory const directory;