Skip to content

[feat](extensions) Publish signed extension repository - #622

Open
kaka11chen wants to merge 2 commits into
AstroVela:feat/issue-613-trusted-extension-descriptorsfrom
kaka11chen:feat/issue-614-signed-extension-repository
Open

kaka11chen wants to merge 2 commits into
AstroVela:feat/issue-613-trusted-extension-descriptorsfrom
kaka11chen:feat/issue-614-signed-extension-repository

Conversation

@kaka11chen

Copy link
Copy Markdown
Contributor

Summary

  • Publish the exact descriptor and .duckdb_extension bytes from independent platform wheels into an append-only, Ed25519-signed Vane repository index.
  • Add an explicit pinned-repository installer with strict signature, signer, runtime identity, platform, digest, dependency-closure, footer, redirect, and cache validation; it has no default repository, unsigned URL, or cache fallback.
  • Keep cryptographic verification in the opt-in extensions extra, provide publish/install CLIs and development documentation, and keep Ray repository transport out of worker execution.

This PR stacks on the platform-wheel contract in #620. Its GitHub base is #618's branch because #620 is fork-only and is not a branch in the upstream repository; it should merge after #620.

Related issue

close: #614

Documentation impact

  • No user-facing documentation update is required.
  • Documentation is updated in this PR or in a linked website PR.
  • A follow-up issue is required in
    AstroVela/vane-website.

DEVELOPMENT.md now covers signed repository publishing, key pinning, local cache recovery, and the Ray network boundary.

Validation

  • scripts/format root --changed and pre-commit run --files ... (Ruff, Mypy, YAML/TOML, private-key scan, and merge-conflict checks).
  • Native package build/install with CMAKE_BUILD_PARALLEL_LEVEL=16, VANE_NATIVE_BUILD_JOBS=16, shared vcpkg Arrow/OpenSSL paths, and uv pip install '.[extensions]' --no-build-isolation.
  • scripts/run_installed_pytest.sh tests/fast/test_extension_repository.py tests/fast/test_package_metadata.py — 34 passed, 1 optional ADBC skip.
  • scripts/run_release_tests.sh — completed through isolated non-Ray and real-Ray shards.

Checklist

  • The change is focused and includes tests or a reason tests are unnecessary.
  • Public behavior and compatibility impact are documented. The repository API is opt-in and intentionally has no unsigned/default-repository or compatibility fallback.
  • New dependencies, copied code, model assets, and datasets have compatible licenses and are recorded where required. cryptography is a compatible optional dependency declared in package metadata and the type-check hook.
  • No credentials, private endpoints, personal paths, generated data, model weights, or build artifacts are included.
  • Security implications of UDFs, serialization, remote code, network access, and untrusted input have been considered. The implementation pins an Ed25519 key, validates canonical signed metadata before paths are trusted, rejects HTTP/unsafe redirects, and revalidates local bytes through the resolver.
  • Native changes were compiled; Python-only, documentation, and workflow changes passed relevant checks.

Copilot AI lite review requested due to automatic review settings August 18, 2026 07:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9ae1b939e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +600 to +601
_atomic_write(destination / _INDEX_FILE_NAME, index_bytes)
_atomic_write(destination / _SIGNATURE_FILE_NAME, _canonical_json_bytes(signature) + b"\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Publish index and signature as one atomic generation

If publishing is interrupted after the first _atomic_write, repository.json contains the new generation while repository.sig.json still signs the old one. Every installer then rejects the repository, and rerunning the publisher cannot repair it because _read_existing_manifest rejects the mismatched pair before publishing. Serving the output directory directly also exposes this mismatch to concurrent readers, so the signed metadata needs a single atomic generation/pointer update.

Useful? React with 👍 / 👎.

Comment on lines +183 to +187
def to_dict(self) -> dict[str, object]:
return {
"artifacts": [artifact.to_dict() for artifact in self.artifacts],
"format_version": _REPOSITORY_FORMAT_VERSION,
"repository_id": self.repository_id,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add a monotonic version to prevent signed-index rollback

If a repository host, mirror, or cache replays an older correctly signed index, the client has no signed generation or local checkpoint with which to detect the rollback. This can make newer releases disappear and, when extension_version is omitted, silently select an older previously approved extension; Ed25519 verification alone only proves who signed the stale state. Include a monotonic signed repository version and persist/enforce the highest observed value.

Useful? React with 👍 / 👎.

Comment on lines +106 to +110
vane_version = _validate_wheel_version(vane.__version__)
distribution_name = f"vane-extension-{name}"
distribution_root = f"vane_extension_{name}-{vane_version}"
wheel_tag = f"py3-none-{normalized_platform_tag}"
output_path = Path(output_directory).expanduser().resolve() / f"{distribution_root}-{wheel_tag}.whl"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include the extension release in the wheel identity

When two extension releases are built for the same Vane version, name, and platform, both receive the identical wheel filename and distribution version because descriptor.extension_version is not represented here. The second build is therefore rejected as a conflicting existing wheel, and an artifact index cannot retain both independently distributed releases under distinct filenames, despite the repository supporting exact extension-version selection. Encode the extension release in a valid wheel version or build tag.

Useful? React with 👍 / 👎.

Comment on lines +91 to +95
descriptor = create_dynamic_extension_descriptor(
artifact_path,
name=name,
trust_identity=trust_identity,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve declared dependencies when building extension wheels

For an extension that depends on another dynamic extension, the supported wheel builder always calls create_dynamic_extension_descriptor without its dependencies argument, producing an empty dependency tuple. Publishing that wheel therefore gives the installer no closure to download, and DynamicExtensionResolver will attempt to resolve/load only the root artifact. Expose dependency descriptors through this API and CLI, or derive them from authoritative build metadata.

Useful? React with 👍 / 👎.

Comment on lines +500 to +506
response = urllib.request.urlopen(request, timeout=self._timeout_seconds)
except (urllib.error.URLError, OSError) as exception:
raise DynamicExtensionError(
"REPOSITORY_DOWNLOAD_FAILED", f"could not download {label} from configured repository"
) from exception
response_url = response.geturl()
if not _url_is_within_repository(response_url, self._repository_url):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate every redirect before following it

If the pinned repository returns a redirect to an external HTTPS host and that host redirects back under the repository URL, urllib.request.urlopen follows both hops automatically and response.geturl() reports only the final in-repository URL. The check therefore passes even though the client made an outbound request outside the pinned repository, violating the stated repository and network boundary. Use a redirect handler that validates each target before following it.

Useful? React with 👍 / 👎.

"REPOSITORY_DESCRIPTOR_MISMATCH",
f"repository descriptor file does not match signed index for {entry.descriptor.identity}",
)
_atomic_write(descriptor_cache_path, descriptor_bytes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report descriptor cache writes as cache failures

When installation cannot create or replace the cached descriptor—for example because the cache is read-only or full—this call reaches _atomic_write, whose hard-coded exception path raises REPOSITORY_PUBLISH_FAILED and says it could not write a repository index. Consumers consequently receive the wrong public error category during an install and cannot handle cache failures consistently with the artifact-write path. Give cache writes their own error code/message or parameterize the helper.

Useful? React with 👍 / 👎.

Comment on lines +936 to +938
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("xb") as output:
output.write(data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Write immutable artifacts through temporary files

If output.write(data) fails partway through because the process is interrupted or the filesystem runs out of space, the newly created immutable path remains as a truncated file. A later publish cannot recover: opening with xb raises FileExistsError, the byte comparison fails, and the publisher refuses to replace the partial file, requiring manual repository repair. Write to a sibling temporary file and atomically install it only after the complete contents are durable.

Useful? React with 👍 / 👎.

Comment on lines +577 to +579
existing_entry = entries.get(descriptor.identity)
if existing_entry is not None and existing_entry != entry:
_fail("REPOSITORY_PUBLISH_FAILED", f"repository identity changed paths: {descriptor.identity}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Distinguish descriptors that reuse identical artifact bytes

When the same byte-identical artifact is packaged for another Vane runtime—particularly a stable C_STRUCT extension whose footer and binary can legitimately be reused—the new descriptor has the same name, extension version, and SHA-256 identity but a different vane_version or duckdb_source_id. This lookup treats it as the existing entry and then rejects the differing descriptor paths, so the append-only repository cannot publish that artifact for both supported runtimes. Key repository entries and dependency references by the complete immutable descriptor coordinate rather than only the artifact identity.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants