diff --git a/.gitattributes b/.gitattributes index a313521e..d179afe7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,13 @@ # Generated TSVs keep trailing empty columns. *.tsv text eol=lf linguist-generated=true whitespace=-blank-at-eol + +# Legal files are verified byte-for-byte. +LICENSE text eol=lf +NOTICE text eol=lf +**/LICENSE text eol=lf +**/NOTICE text eol=lf +**/THIRD-PARTY-LICENSES*.html text eol=lf linguist-generated=true +third-party-licenses/* text eol=lf + +# Generated wheel legal files must never enter the ASF source archive. +bindings/python/licenses/** export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c10b63f..3113ba80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,51 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Detect release license inputs + id: release_license_inputs + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == "pull_request" ]]; then + base="origin/$BASE_REF" + elif [[ -n "$BEFORE_SHA" && ! "$BEFORE_SHA" =~ ^0+$ ]] && \ + git cat-file -e "$BEFORE_SHA^{commit}"; then + base="$BEFORE_SHA" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if git diff --quiet "$base" HEAD -- \ + .gitattributes \ + LICENSE \ + NOTICE \ + ':(glob)**/LICENSE' \ + ':(glob)**/LICENSE.*' \ + ':(glob)**/NOTICE' \ + ':(glob)**/NOTICE.*' \ + Cargo.lock \ + about.hbs \ + about.toml \ + bindings/go/LICENSE \ + bindings/go/NOTICE \ + ':(glob)bindings/go/THIRD-PARTY-LICENSES.*.html' \ + bindings/python/licenses \ + bindings/python/THIRD-PARTY-LICENSES.html \ + scripts/release_licenses.py \ + third-party-licenses \ + ':(glob)**/Cargo.toml'; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi - name: Validate .asf.yaml run: python3 scripts/validate_asf_yaml.py @@ -46,11 +91,20 @@ jobs: - name: Check License Header uses: apache/skywalking-eyes/header@v0.8.0 + - name: Test release artifact verifiers + run: python3 -m unittest discover -s scripts/tests -p 'test_*.py' + - name: Install cargo-deny uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2.84.0 with: tool: cargo-deny@0.19.6 + - name: Install cargo-about + if: steps.release_license_inputs.outputs.changed == 'true' + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2.84.0 + with: + tool: cargo-about@0.9.1 + - name: Fetch locked dependencies run: cargo fetch --locked @@ -60,6 +114,10 @@ jobs: - name: Verify dependency reports run: python3 scripts/dependencies.py verify + - name: Verify release license files + if: steps.release_license_inputs.outputs.changed == 'true' + run: python3 scripts/release_licenses.py --check + - name: Format run: cargo fmt --all -- --check diff --git a/.github/workflows/release-go-binding.yml b/.github/workflows/release-go-binding.yml index efa7bf2e..9d61675d 100644 --- a/.github/workflows/release-go-binding.yml +++ b/.github/workflows/release-go-binding.yml @@ -36,7 +36,7 @@ on: type: string permissions: - contents: write + contents: read jobs: validate: @@ -103,12 +103,20 @@ jobs: include: - runner: ubuntu-latest expected_file: libpaimon_c.linux.amd64.so.zst + rust_target: x86_64-unknown-linux-gnu + license_file: THIRD-PARTY-LICENSES.linux.amd64.html - runner: ubuntu-24.04-arm expected_file: libpaimon_c.linux.arm64.so.zst + rust_target: aarch64-unknown-linux-gnu + license_file: THIRD-PARTY-LICENSES.linux.arm64.html - runner: macos-15-intel expected_file: libpaimon_c.darwin.amd64.dylib.zst + rust_target: x86_64-apple-darwin + license_file: THIRD-PARTY-LICENSES.darwin.amd64.html - runner: macos-14 expected_file: libpaimon_c.darwin.arm64.dylib.zst + rust_target: aarch64-apple-darwin + license_file: THIRD-PARTY-LICENSES.darwin.arm64.html steps: - uses: actions/checkout@v7 @@ -120,6 +128,10 @@ jobs: rustup update stable rustup default stable + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Cache Rust build outputs uses: actions/cache@v6 with: @@ -131,6 +143,11 @@ jobs: target/ key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Install cargo-about + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2.84.0 + with: + tool: cargo-about@0.9.1 + - name: Install zstd on Linux if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y zstd @@ -143,17 +160,27 @@ jobs: working-directory: bindings/go run: make build - - name: Verify packaged library - working-directory: bindings/go + - name: Stage and verify packaged library shell: bash run: | - test -s '${{ matrix.expected_file }}' + mkdir -p release/go + cargo fetch --locked + python3 scripts/release_licenses.py \ + --generate-go-report \ + '${{ matrix.rust_target }}' \ + release/go + cp 'bindings/go/${{ matrix.expected_file }}' release/go/ + python3 scripts/verify_go_release_artifacts.py \ + --artifacts-dir release/go \ + --target '${{ matrix.rust_target }}' - name: Upload packaged library uses: actions/upload-artifact@v7 with: name: ${{ matrix.expected_file }} - path: bindings/go/${{ matrix.expected_file }} + path: | + release/go/${{ matrix.expected_file }} + release/go/${{ matrix.license_file }} if-no-files-found: error publish: @@ -161,30 +188,41 @@ jobs: - validate - build runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ needs.validate.outputs.source_sha }} + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install cargo-about + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2.84.0 + with: + tool: cargo-about@0.9.1 + + - name: Fetch locked release inputs + run: cargo fetch --locked + + - name: Install artifact verification tools + run: sudo apt-get update && sudo apt-get install -y file zstd + - name: Download packaged libraries uses: actions/download-artifact@v8 with: - path: bindings/go + path: release/go merge-multiple: true - - name: Verify embedded assets - working-directory: bindings/go + - name: Generate and verify release legal files shell: bash run: | - for file in \ - libpaimon_c.linux.amd64.so.zst \ - libpaimon_c.linux.arm64.so.zst \ - libpaimon_c.darwin.amd64.dylib.zst \ - libpaimon_c.darwin.arm64.dylib.zst - do - test -s "$file" - done + python3 scripts/release_licenses.py \ + --generate-go-release release/go + python3 scripts/verify_go_release_artifacts.py --artifacts-dir release/go - name: Create release tag commit env: @@ -197,7 +235,17 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add bindings/go/libpaimon_c.*.zst + cp release/go/libpaimon_c.*.zst bindings/go/ + cp \ + release/go/LICENSE \ + release/go/NOTICE \ + release/go/THIRD-PARTY-LICENSES.*.html \ + bindings/go/ + git add \ + bindings/go/libpaimon_c.*.zst \ + bindings/go/LICENSE \ + bindings/go/NOTICE \ + bindings/go/THIRD-PARTY-LICENSES.*.html if ! git diff --cached --quiet; then git commit -m "bindings/go: release ${VERSION} (from ${SOURCE_REF}@${SOURCE_SHA})" @@ -211,12 +259,18 @@ jobs: TAG: ${{ needs.validate.outputs.tag }} VERSION: ${{ needs.validate.outputs.version }} GH_TOKEN: ${{ github.token }} - working-directory: bindings/go + working-directory: release/go run: | gh release create "$TAG" \ --title "Release Go binding $VERSION" \ --generate-notes \ + LICENSE \ + NOTICE \ libpaimon_c.linux.amd64.so.zst \ libpaimon_c.linux.arm64.so.zst \ libpaimon_c.darwin.amd64.dylib.zst \ - libpaimon_c.darwin.arm64.dylib.zst + libpaimon_c.darwin.arm64.dylib.zst \ + THIRD-PARTY-LICENSES.linux.amd64.html \ + THIRD-PARTY-LICENSES.linux.arm64.html \ + THIRD-PARTY-LICENSES.darwin.amd64.html \ + THIRD-PARTY-LICENSES.darwin.arm64.html diff --git a/.github/workflows/release_python_binding.yml b/.github/workflows/release_python_binding.yml index c6b0b89b..b2b95823 100644 --- a/.github/workflows/release_python_binding.yml +++ b/.github/workflows/release_python_binding.yml @@ -53,41 +53,72 @@ jobs: with: ref: ${{ env.RELEASE_TAG }} + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Set Python release version + shell: bash run: > python3 scripts/python_release_version.py --tag "${RELEASE_TAG}" --pyproject bindings/python/pyproject.toml + - name: Verify Cargo lockfile + run: cargo metadata --locked --no-deps --format-version 1 > /dev/null + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: + maturin-version: v1.14.1 working-directory: bindings/python command: sdist args: -o dist - before-script-linux: | - sudo apt-get update && sudo apt-get install -y libssl-dev + + - name: Verify sdist + run: python scripts/verify_python_wheels.py --allow-partial bindings/python/dist - name: Upload sdist uses: actions/upload-artifact@v7 with: name: wheels-sdist - path: bindings/python/dist + path: bindings/python/dist/*.tar.gz wheels: runs-on: ${{ matrix.os }} strategy: matrix: include: - - { os: windows-latest } - - { os: macos-15-intel, target: "x86_64-apple-darwin" } - - { os: macos-latest, target: "aarch64-apple-darwin" } - - { os: ubuntu-latest, target: "x86_64" } - - { os: ubuntu-latest, target: "aarch64", manylinux: "manylinux_2_28" } + - os: windows-latest + target: "x86_64-pc-windows-msvc" + rust_target: "x86_64-pc-windows-msvc" + - os: macos-15-intel + target: "x86_64-apple-darwin" + rust_target: "x86_64-apple-darwin" + - os: macos-latest + target: "aarch64-apple-darwin" + rust_target: "aarch64-apple-darwin" + - os: ubuntu-latest + target: "x86_64-unknown-linux-gnu" + rust_target: "x86_64-unknown-linux-gnu" + manylinux: "manylinux2014" + - os: ubuntu-latest + target: "aarch64-unknown-linux-gnu" + rust_target: "aarch64-unknown-linux-gnu" + manylinux: "manylinux_2_28" steps: - uses: actions/checkout@v7 with: ref: ${{ env.RELEASE_TAG }} + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install cargo-about + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2.84.0 + with: + tool: cargo-about@0.9.1 + - name: Set Python release version shell: bash run: > @@ -95,16 +126,22 @@ jobs: --tag "${RELEASE_TAG}" --pyproject bindings/python/pyproject.toml + - name: Generate wheel legal files + run: | + cargo fetch --locked + python3 scripts/release_licenses.py --stage-python ${{ matrix.rust_target }} + - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: + maturin-version: v1.14.1 working-directory: bindings/python target: ${{ matrix.target }} command: build - args: --release -o dist + args: --release --locked -o dist manylinux: ${{ matrix.manylinux || 'auto' }} before-script-linux: | # Install OpenSSL for x86_64 (manylinux2014 with OpenSSL 1.1) - if [ "${{ matrix.target }}" = "x86_64" ]; then + if [ "${{ matrix.rust_target }}" = "x86_64-unknown-linux-gnu" ]; then yum install -y openssl11-devel # Create symlinks so pkg-config finds openssl11 as openssl ln -sf /usr/lib64/pkgconfig/openssl11.pc /usr/lib64/pkgconfig/openssl.pc @@ -113,15 +150,23 @@ jobs: fi # Install precompiled OpenSSL from Debian packages for aarch64 - if [ "${{ matrix.target }}" = "aarch64" ]; then + if [ "${{ matrix.rust_target }}" = "aarch64-unknown-linux-gnu" ]; then set -e echo "Installing OpenSSL for aarch64 from Debian packages..." (cd /tmp - # Debian Bullseye arm64 packages (OpenSSL 1.1.1) - DEBIAN_MIRROR="http://ftp.debian.org/debian/pool/main/o/openssl" - curl -sLO "${DEBIAN_MIRROR}/libssl1.1_1.1.1w-0+deb11u1_arm64.deb" - curl -sLO "${DEBIAN_MIRROR}/libssl-dev_1.1.1w-0+deb11u1_arm64.deb" + DEBIAN_MIRROR="https://snapshot.debian.org/archive/debian/20231003T205808Z/pool/main/o/openssl" + LIBSSL_DEB="libssl1.1_1.1.1w-0+deb11u1_arm64.deb" + LIBSSL_SHA256="fe7a7d313c87e46e62e614a07137e4a476a79fc9e5aab7b23e8235211280fee3" + LIBSSL_DEV_DEB="libssl-dev_1.1.1w-0+deb11u1_arm64.deb" + LIBSSL_DEV_SHA256="6223f761bd4b961aa0c7c1662a6e99ab31150a1a7e6529d440428b361a9c8c4a" + + curl --fail --location --show-error --silent \ + --output "${LIBSSL_DEB}" "${DEBIAN_MIRROR}/${LIBSSL_DEB}" + curl --fail --location --show-error --silent \ + --output "${LIBSSL_DEV_DEB}" "${DEBIAN_MIRROR}/${LIBSSL_DEV_DEB}" + printf '%s %s\n' "${LIBSSL_SHA256}" "${LIBSSL_DEB}" | sha256sum -c - + printf '%s %s\n' "${LIBSSL_DEV_SHA256}" "${LIBSSL_DEV_DEB}" | sha256sum -c - # Extract .deb packages (ar format) for deb in *.deb; do @@ -156,11 +201,14 @@ jobs: export CFLAGS_aarch64_unknown_linux_gnu="-D__ARM_ARCH=8" fi + - name: Verify wheel + run: python scripts/verify_python_wheels.py --allow-partial bindings/python/dist + - name: Upload wheels uses: actions/upload-artifact@v7 with: - name: wheels-${{ matrix.os }}-${{ matrix.target || 'native' }} - path: bindings/python/dist + name: wheels-${{ matrix.os }}-${{ matrix.rust_target }} + path: bindings/python/dist/*.whl release: name: Publish to PyPI @@ -170,12 +218,34 @@ jobs: needs: [sdist, wheels] if: startsWith(inputs.tag || github.ref_name, 'v') steps: + - uses: actions/checkout@v7 + with: + ref: ${{ env.RELEASE_TAG }} + + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install cargo-about + uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2.84.0 + with: + tool: cargo-about@0.9.1 + + - name: Generate expected wheel legal files + run: | + cargo fetch --locked + python3 scripts/release_licenses.py \ + --generate-python-reports bindings/python/licenses + - uses: actions/download-artifact@v8 with: pattern: wheels-* merge-multiple: true path: bindings/python/dist + - name: Verify release artifacts + run: python scripts/verify_python_wheels.py bindings/python/dist + - name: Publish to TestPyPI if: contains(env.RELEASE_TAG, '-') uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.licenserc.yaml b/.licenserc.yaml index 44db53da..c3cac61b 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -28,6 +28,7 @@ header: - ".github/PULL_REQUEST_TEMPLATE.md" - "crates/paimon/tests/**/*.json" - "crates/paimon/testdata/**" + - "third-party-licenses/openssl-1.1.1.LICENSE" - "**/go.sum" - "**/DEPENDENCIES.*.tsv" - ".devcontainer/devcontainer.json" diff --git a/about.hbs b/about.hbs new file mode 100644 index 00000000..3ecdc9cb --- /dev/null +++ b/about.hbs @@ -0,0 +1,80 @@ +{{! + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +}} + + + + + + Apache Paimon Rust third-party licenses + + + + +
+

Bundled Rust Components and Licenses

+

+ This document lists components distributed in an Apache Paimon + Rust binary artifact, together with their versions, copyright + notices, and complete license texts. +

+ +

Overview of licenses

+ + +

Rust crate license texts

+ +
+ + + diff --git a/about.toml b/about.toml new file mode 100644 index 00000000..ed5d0c06 --- /dev/null +++ b/about.toml @@ -0,0 +1,36 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +accepted = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "MIT", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MPL-2.0", + "Unicode-3.0", + "Zlib", + "bzip2-1.0.6", +] + +# Report linked runtime components only. +ignore-build-dependencies = true +ignore-dev-dependencies = true diff --git a/bindings/go/RELEASE.md b/bindings/go/RELEASE.md index 9fd547b9..2c1c19f6 100644 --- a/bindings/go/RELEASE.md +++ b/bindings/go/RELEASE.md @@ -76,6 +76,8 @@ submodule tag to contain the embedded artifacts required by Go module consumers. - `libpaimon_c.linux.arm64.so.zst` - `libpaimon_c.darwin.amd64.dylib.zst` - `libpaimon_c.darwin.arm64.dylib.zst` +- Confirm `python3 scripts/release_licenses.py --check` can reproduce all binary + legal files from the locked dependency graph. - Confirm CI is green on `main` before publishing. ## Publish @@ -91,16 +93,20 @@ submodule tag to contain the embedded artifacts required by Go module consumers. - `linux/arm64` - `darwin/amd64` - `darwin/arm64` +- Confirm each build job generates and verifies the target-specific legal report. - Confirm the workflow creates the annotated tag `bindings/go/v0.1.0`. - Confirm the workflow publishes a GitHub release for that tag. ## After Release -- Confirm the release contains all four compressed shared-library assets. +- Confirm the release contains LICENSE, NOTICE, four compressed shared libraries, + and four matching third-party license reports. - Confirm the tag resolves to a commit that contains: - `bindings/go/go.mod` - `bindings/go/RELEASE.md` - the four `libpaimon_c.*.zst` files + - `bindings/go/LICENSE` and `bindings/go/NOTICE` + - the four `bindings/go/THIRD-PARTY-LICENSES.*.html` files - Record which Rust release tag or baseline commit this Go binding release was built from. - Verify module resolution from a clean environment: diff --git a/bindings/python/THIRD-PARTY-LICENSES.html b/bindings/python/THIRD-PARTY-LICENSES.html new file mode 100644 index 00000000..29afced7 --- /dev/null +++ b/bindings/python/THIRD-PARTY-LICENSES.html @@ -0,0 +1,33 @@ + + + + + + Apache Paimon Rust Python source distribution licenses + + +

Third-party licenses for the Python source distribution

+

This source distribution contains no compiled native library.

+

+ Published wheels replace this file with a target-specific report + generated from Cargo.lock by scripts/release_licenses.py. +

+ + diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index b2d94e63..131404e9 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -24,7 +24,8 @@ name = "pypaimon-rust" readme = "project-description.md" requires-python = ">=3.10,<4" dynamic = ["version"] -license = { file = "LICENSE" } +license = "Apache-2.0" +license-files = ["LICENSE", "NOTICE", "THIRD-PARTY-LICENSES.html"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -49,10 +50,14 @@ video = [ module-name = "pypaimon_rust.pypaimon_rust" python-source = "python" include = [ - { path = "LICENSE", format = ["sdist", "wheel"] }, - { path = "NOTICE", format = ["sdist", "wheel"] }, + { path = "LICENSE", format = "wheel" }, + { path = "NOTICE", format = "wheel" }, + { path = "THIRD-PARTY-LICENSES.html", format = "wheel" }, { path = "python/pypaimon_rust/py.typed", format = ["sdist", "wheel"] }, ] +exclude = [ + { path = "licenses/**", format = "sdist" }, +] [dependency-groups] dev = [ diff --git a/scripts/release_licenses.py b/scripts/release_licenses.py new file mode 100644 index 00000000..8c6e6323 --- /dev/null +++ b/scripts/release_licenses.py @@ -0,0 +1,916 @@ +#!/usr/bin/env python3 + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generate artifact-exact legal metadata for native release artifacts.""" + +from __future__ import annotations + +import argparse +import html +import json +import re +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + + +CARGO_ABOUT_VERSION = "0.9.1" +PYTHON_TARGETS = ( + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", +) +GO_REPORTS = { + "x86_64-unknown-linux-gnu": "THIRD-PARTY-LICENSES.linux.amd64.html", + "aarch64-unknown-linux-gnu": "THIRD-PARTY-LICENSES.linux.arm64.html", + "x86_64-apple-darwin": "THIRD-PARTY-LICENSES.darwin.amd64.html", + "aarch64-apple-darwin": "THIRD-PARTY-LICENSES.darwin.arm64.html", +} + + +@dataclass(frozen=True) +class Report: + component: str + manifest: str + target: str + output: str + + +@dataclass(frozen=True) +class LicenseCorrection: + crates: tuple[str, ...] + license_crate: str + license_path: str + license_name: str + anchor: str + + +@dataclass(frozen=True) +class BundledComponent: + crate: str + license_path: str + component: str + component_url: str + license_name: str + anchor: str + components: tuple[str, ...] = () + targets: tuple[str, ...] = () + license_from_repository: bool = False + required: bool = False + relationship: str = "bundled by" + crate_version: str = "" + required_features: tuple[str, ...] = () + + +ALLOC_CORRECTIONS = ( + LicenseCorrection( + crates=("alloc-stdlib",), + license_crate="alloc-no-stdlib", + license_path="LICENSE", + license_name="BSD 3-Clause License (rust-alloc-no-stdlib)", + anchor="bsd-rust-alloc-no-stdlib", + ), + LicenseCorrection( + crates=("aws-lc-sys",), + license_crate="aws-lc-sys", + license_path="LICENSE", + license_name="AWS-LC License", + anchor="aws-lc-license", + ), +) + +COMMON_MIT_CORRECTIONS = ( + LicenseCorrection( + crates=("async-stream", "async-stream-impl"), + license_crate="async-stream", + license_path="LICENSE", + license_name="MIT License (async-stream workspace)", + anchor="mit-async-stream-workspace", + ), + LicenseCorrection( + crates=("brotli-decompressor",), + license_crate="brotli-decompressor", + license_path="LICENSE", + license_name="MIT License (brotli-decompressor)", + anchor="mit-brotli-decompressor", + ), + LicenseCorrection( + crates=("libm",), + license_crate="libm", + license_path="LICENSE.txt", + license_name="MIT License (libm)", + anchor="mit-libm", + ), +) + +PYTHON_MIT_CORRECTIONS = ( + LicenseCorrection( + crates=( + "ownedbytes", + "tantivy-bitpacker", + "tantivy-columnar", + "tantivy-common", + "tantivy-query-grammar", + "tantivy-sstable", + "tantivy-stacker", + "tantivy-tokenizer-api", + ), + license_crate="tantivy", + license_path="LICENSE", + license_name="MIT License (Tantivy workspace)", + anchor="mit-tantivy-workspace", + ), +) + +BUNDLED_COMPONENTS = ( + BundledComponent( + crate="zstd-sys", + license_path="zstd/LICENSE", + component="vendored Zstandard C sources", + component_url="https://github.com/facebook/zstd", + license_name="BSD 3-Clause License", + anchor="bundled-zstandard-bsd-3-clause", + ), + BundledComponent( + crate="rust-stemmers", + license_path="algorithms/LICENSE", + component="generated Snowball stemming algorithms", + component_url="https://snowballstem.org/", + license_name="BSD 3-Clause License", + anchor="bundled-snowball-bsd-3-clause", + ), + BundledComponent( + crate="regex-syntax", + license_path="src/unicode_tables/LICENSE-UNICODE", + component="generated Unicode character database tables", + component_url="https://www.unicode.org/", + license_name="Unicode Data Files and Software License", + anchor="bundled-regex-syntax-unicode", + ), + BundledComponent( + crate="liblzma-sys", + license_path="xz/COPYING.0BSD", + component="XZ Utils 5.8.3 liblzma C sources", + component_url="https://github.com/tukaani-project/xz/tree/v5.8.3", + license_name="BSD Zero Clause License", + anchor="bundled-xz-utils-0bsd", + components=("python",), + required=True, + relationship="statically linked through", + crate_version="0.4.7", + required_features=("static",), + ), + BundledComponent( + crate="openssl-sys", + license_path="third-party-licenses/openssl-1.1.1.LICENSE", + component="OpenSSL 1.1.1k FIPS libssl and libcrypto shared libraries", + component_url="https://github.com/openssl/openssl/tree/OpenSSL_1_1_1k", + license_name="OpenSSL 1.1.1 and Original SSLeay Licenses", + anchor="bundled-openssl-1.1.1", + components=("python",), + targets=("x86_64-unknown-linux-gnu",), + license_from_repository=True, + required=True, + relationship="linked through", + ), + BundledComponent( + crate="openssl-sys", + license_path="third-party-licenses/openssl-1.1.1.LICENSE", + component="OpenSSL 1.1.1w libssl and libcrypto shared libraries", + component_url="https://github.com/openssl/openssl/tree/OpenSSL_1_1_1w", + license_name="OpenSSL 1.1.1 and Original SSLeay Licenses", + anchor="bundled-openssl-1.1.1", + components=("python",), + targets=("aarch64-unknown-linux-gnu",), + license_from_repository=True, + required=True, + relationship="linked through", + ), +) + +ALLOC_PLACEHOLDER = "Copyright (c) <year> <owner>." +MIT_PLACEHOLDER = "Copyright (c) <year> <copyright holders>" +LICENSE_PLACEHOLDERS = ( + "<year>", + "<owner>", + "<copyright holders>", + "", + "", + "", +) +ASF_DEVELOPED_NOTICE = re.compile( + r"This product includes software developed at\n" + r"The Apache Software Foundation \(https?://www\.apache\.org/\)\." +) + + +def repository_root() -> Path: + return Path( + subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], text=True + ).strip() + ) + + +def report_specs() -> list[Report]: + reports = [ + Report( + component="python", + manifest="bindings/python/Cargo.toml", + target=target, + output=(f"bindings/python/licenses/{target}/THIRD-PARTY-LICENSES.html"), + ) + for target in PYTHON_TARGETS + ] + reports.extend( + Report( + component="go", + manifest="bindings/c/Cargo.toml", + target=target, + output=f"bindings/go/{filename}", + ) + for target, filename in GO_REPORTS.items() + ) + return reports + + +def verify_cargo_about(root: Path) -> None: + output = subprocess.check_output( + ["cargo", "about", "--version"], cwd=root, text=True + ).strip() + actual = output.rsplit(" ", 1)[-1] + if actual != CARGO_ABOUT_VERSION: + raise RuntimeError( + f"cargo-about {CARGO_ABOUT_VERSION} is required, found {output!r}" + ) + + +def cargo_metadata(root: Path, report: Report) -> dict: + output = subprocess.check_output( + [ + "cargo", + "metadata", + "--locked", + "--format-version", + "1", + "--manifest-path", + report.manifest, + "--filter-platform", + report.target, + ], + cwd=root, + text=True, + ) + metadata = json.loads(output) + tree = subprocess.check_output( + [ + "cargo", + "tree", + "--locked", + "--manifest-path", + report.manifest, + "--target", + report.target, + "--edges", + "normal", + "--prefix", + "none", + "--format", + "{p}", + ], + cwd=root, + text=True, + ) + packages = set() + for line in tree.splitlines(): + match = re.match(r"^([A-Za-z0-9_-]+) v([^ ]+)", line) + if match is None: + raise RuntimeError(f"unexpected cargo tree package line: {line!r}") + packages.add(match.groups()) + metadata["normal_packages"] = packages + return metadata + + +def generate_base_report(root: Path, report: Report, output: Path) -> str: + subprocess.run( + [ + "cargo", + "about", + "generate", + "--frozen", + "--fail", + "--config", + str(root / "about.toml"), + "--manifest-path", + report.manifest, + "--target", + report.target, + "--output-file", + str(output), + str(root / "about.hbs"), + ], + cwd=root, + check=True, + ) + return output.read_text(encoding="utf-8") + + +def resolved_packages(metadata: dict) -> list[dict]: + normal_packages = metadata["normal_packages"] + return [ + package + for package in metadata["packages"] + if (package["name"], package["version"]) in normal_packages + ] + + +def package_by_name( + metadata: dict, crate_name: str, required: bool = True +) -> dict | None: + matches = [ + package + for package in resolved_packages(metadata) + if package["name"] == crate_name + ] + if not matches and not required: + return None + if len(matches) != 1: + versions = [package["version"] for package in matches] + raise RuntimeError( + f"expected exactly one resolved {crate_name} package, found {versions}" + ) + return matches[0] + + +def package_features(metadata: dict, package: dict) -> set[str]: + matches = [ + node["features"] + for node in metadata["resolve"]["nodes"] + if node["id"] == package["id"] + ] + if len(matches) != 1: + raise RuntimeError(f"could not resolve features for {package['name']}") + return set(matches[0]) + + +def correction_html(metadata: dict, correction: LicenseCorrection) -> str: + used_by = [] + for crate_name in correction.crates: + package = package_by_name(metadata, crate_name) + repository = package.get("repository") or ( + f"https://crates.io/crates/{crate_name}" + ) + used_by.append( + f'
  • ' + f"{html.escape(crate_name)} {html.escape(package['version'])}
  • " + ) + + license_package = package_by_name(metadata, correction.license_crate) + license_file = ( + Path(license_package["manifest_path"]).parent / correction.license_path + ) + if not license_file.is_file(): + raise RuntimeError(f"corrected license file is missing: {license_file}") + license_text = license_file.read_text(encoding="utf-8") + + return "\n".join( + [ + '
  • ', + f'

    ' + f"{html.escape(correction.license_name)}

    ", + "

    Used by

    ", + '
      ', + *used_by, + "
    ", + f'
    {html.escape(license_text)}
    ', + "
  • ", + ] + ) + + +def replace_placeholder_entry( + report_text: str, + metadata: dict, + marker: str, + corrections: tuple[LicenseCorrection, ...], +) -> str: + if report_text.count(marker) != 1: + raise RuntimeError( + f"expected exactly one license placeholder {marker!r}, " + f"found {report_text.count(marker)}" + ) + marker_index = report_text.index(marker) + entry_start = report_text.rfind('
  • ', 0, marker_index) + entry_end = report_text.find("
  • ", marker_index) + if entry_start == -1 or entry_end == -1: + raise RuntimeError(f"could not locate license entry for {marker!r}") + entry_end += len(" ") + placeholder_entry = report_text[entry_start:entry_end] + + actual_crates = set( + re.findall( + r'
  • ([A-Za-z0-9_-]+) [^<]+
  • ', + placeholder_entry, + ) + ) + expected_crates = { + crate_name for correction in corrections for crate_name in correction.crates + } + if actual_crates != expected_crates: + raise RuntimeError( + f"placeholder dependency set changed for {marker!r}: expected " + f"{sorted(expected_crates)}, found {sorted(actual_crates)}" + ) + + replacement = "\n".join( + correction_html(metadata, correction) for correction in corrections + ) + return report_text[:entry_start] + replacement + report_text[entry_end:] + + +def bundled_component_html( + root: Path, report: Report, report_text: str, metadata: dict +) -> str: + items = [] + for component in BUNDLED_COMPONENTS: + if component.components and report.component not in component.components: + continue + if component.targets and report.target not in component.targets: + continue + package = package_by_name(metadata, component.crate, required=False) + if package is None: + if component.required: + raise RuntimeError( + f"required bundled component crate is missing: {component.crate}" + ) + continue + if component.crate_version and package["version"] != component.crate_version: + raise RuntimeError( + f"expected {component.crate} {component.crate_version}, " + f"found {package['version']}" + ) + missing_features = set(component.required_features) - package_features( + metadata, package + ) + if missing_features: + raise RuntimeError( + f"{component.crate} is missing required features: " + f"{sorted(missing_features)}" + ) + marker = f">{component.crate} {package['version']}" + if marker not in report_text: + if component.required: + raise RuntimeError( + f"required bundled component is absent from report: {component.crate}" + ) + # Cargo metadata may include workspace-unified features. + continue + if component.license_from_repository: + license_file = root / component.license_path + else: + license_file = ( + Path(package["manifest_path"]).parent / component.license_path + ) + if not license_file.is_file(): + raise RuntimeError(f"bundled license file is missing: {license_file}") + license_text = license_file.read_text(encoding="utf-8") + repository = package.get("repository") or ( + f"https://crates.io/crates/{component.crate}" + ) + items.append( + "\n".join( + [ + '
  • ', + f'

    ' + f"{html.escape(component.license_name)}

    ", + "

    Bundled component

    ", + ' ", + f'
    {html.escape(license_text)}
    ', + "
  • ", + ] + ) + ) + + if not items: + return "" + return "\n".join( + [ + "", + "

    Licenses for bundled native and source components

    ", + "

    ", + " These components ship with the native artifact but have", + " license files outside their Rust package metadata.", + "

    ", + '
      ', + *items, + "
    ", + ] + ) + + +def complete_report( + root: Path, base_report: str, report: Report, metadata: dict +) -> str: + result = replace_placeholder_entry( + base_report, metadata, ALLOC_PLACEHOLDER, ALLOC_CORRECTIONS + ) + mit_corrections = COMMON_MIT_CORRECTIONS + if report.component == "python": + mit_corrections += PYTHON_MIT_CORRECTIONS + result = replace_placeholder_entry( + result, metadata, MIT_PLACEHOLDER, mit_corrections + ) + + for placeholder in LICENSE_PLACEHOLDERS: + if placeholder in result: + raise RuntimeError( + f"generated report still contains license placeholder {placeholder!r}" + ) + + description = ( + "\n

    Artifact: " + f"{html.escape(report.component)}

    " + "\n

    Rust target: " + f"{html.escape(report.target)}

    " + "\n

    Root manifest: " + f"{html.escape(report.manifest)}

    " + ) + first_paragraph_end = result.find("

    ") + if first_paragraph_end == -1: + raise RuntimeError("about.hbs output has no introductory paragraph") + first_paragraph_end += len("

    ") + result = result[:first_paragraph_end] + description + result[first_paragraph_end:] + + closing_main = result.rfind(" ") + if closing_main == -1: + raise RuntimeError("about.hbs output has no closing main element") + result = ( + result[:closing_main] + + bundled_component_html(root, report, result, metadata) + + "\n" + + result[closing_main:] + ) + return "\n".join(line.rstrip() for line in result.rstrip().splitlines()) + "\n" + + +def binary_license(apache_license: str, heading: str, reports: list[str]) -> str: + appendix = [ + "", + "=" * 79, + "BUNDLED THIRD-PARTY COMPONENTS", + "=" * 79, + "", + heading, + "The component inventory, copyright notices, and complete license texts", + "are provided in:", + "", + ] + appendix.extend(f" {report}" for report in reports) + return apache_license.rstrip() + "\n" + "\n".join(appendix) + "\n" + + +def notice_paragraphs(text: str) -> list[str]: + normalized = "\n".join(line.rstrip() for line in text.strip().splitlines()) + return [part.strip() for part in re.split(r"\n\s*\n", normalized) if part.strip()] + + +def binary_notice(root: Path, metadata_by_target: list[dict]) -> str: + paragraphs = notice_paragraphs((root / "NOTICE").read_text(encoding="utf-8")) + seen_paragraphs = set(paragraphs) + seen_packages = set() + for metadata in metadata_by_target: + for package in resolved_packages(metadata): + package_key = ( + package["name"], + package["version"], + package["manifest_path"], + ) + if package_key in seen_packages: + continue + seen_packages.add(package_key) + package_dir = Path(package["manifest_path"]).parent + notice_files = sorted( + path + for path in package_dir.iterdir() + if path.is_file() + and path.name.lower() in {"notice", "notice.txt", "notice.md"} + ) + for notice_file in notice_files: + notice_text = notice_file.read_text(encoding="utf-8") + for paragraph in notice_paragraphs(notice_text): + if ASF_DEVELOPED_NOTICE.fullmatch(paragraph): + continue + if paragraph in seen_paragraphs: + continue + seen_paragraphs.add(paragraph) + paragraphs.append(paragraph) + + return "\n\n".join(paragraphs) + "\n" + + +def verify_python_source_legal_files(root: Path) -> None: + for name in ("LICENSE", "NOTICE"): + source = root / name + copy = root / "bindings/python" / name + if source.read_bytes() != copy.read_bytes(): + raise RuntimeError(f"restore bindings/python/{name} before generation") + source_report = root / "bindings/python/THIRD-PARTY-LICENSES.html" + report_text = source_report.read_text(encoding="utf-8") + for marker in ( + "Python source distribution licenses", + "contains no compiled native library", + ): + if marker not in report_text: + raise RuntimeError(f"Python source license report is missing {marker!r}") + + +def generate_reports( + root: Path, reports: list[Report] +) -> tuple[dict[Report, str], dict[Report, dict]]: + verify_cargo_about(root) + result = {} + metadata_by_report = {} + with tempfile.TemporaryDirectory(prefix="paimon-rust-license-reports-") as temp: + temp_root = Path(temp) + for index, report in enumerate(reports): + print(f"generating {report.component} license report for {report.target}") + base = generate_base_report( + root, report, temp_root / f"report-{index}.html" + ) + metadata = cargo_metadata(root, report) + metadata_by_report[report] = metadata + result[report] = complete_report(root, base, report, metadata) + return result, metadata_by_report + + +def python_binary_files( + root: Path, target: str, report: str, metadata: dict +) -> dict[str, str]: + apache_license = (root / "LICENSE").read_text(encoding="utf-8") + return { + "LICENSE": binary_license( + apache_license, + f"This binary wheel bundles the Rust native library for {target}.", + ["THIRD-PARTY-LICENSES.html"], + ), + "NOTICE": binary_notice(root, [metadata]), + "THIRD-PARTY-LICENSES.html": report, + } + + +def python_target_files(root: Path, target: str, output_dir: Path) -> dict[Path, str]: + if target not in PYTHON_TARGETS: + raise ValueError(f"unsupported Python release target: {target}") + verify_python_source_legal_files(root) + report = next( + item + for item in report_specs() + if item.component == "python" and item.target == target + ) + reports, metadata = generate_reports(root, [report]) + return { + output_dir / name: content + for name, content in python_binary_files( + root, target, reports[report], metadata[report] + ).items() + } + + +def python_verification_files(root: Path, output_dir: Path) -> dict[Path, str]: + verify_python_source_legal_files(root) + reports = [report for report in report_specs() if report.component == "python"] + generated, metadata = generate_reports(root, reports) + result = {} + for report in reports: + target_dir = output_dir / report.target + result.update( + { + target_dir / name: content + for name, content in python_binary_files( + root, + report.target, + generated[report], + metadata[report], + ).items() + } + ) + return result + + +def go_target_files(root: Path, target: str, output_dir: Path) -> dict[Path, str]: + if target not in GO_REPORTS: + raise ValueError(f"unsupported Go release target: {target}") + report = next( + item + for item in report_specs() + if item.component == "go" and item.target == target + ) + generated, _ = generate_reports(root, [report]) + return {output_dir / GO_REPORTS[target]: generated[report]} + + +def go_release_files(root: Path, output_dir: Path) -> dict[Path, str]: + reports = [report for report in report_specs() if report.component == "go"] + generated, metadata = generate_reports(root, reports) + apache_license = (root / "LICENSE").read_text(encoding="utf-8") + result = { + output_dir / GO_REPORTS[report.target]: generated[report] for report in reports + } + result[output_dir / "LICENSE"] = binary_license( + apache_license, + "This Go module bundles Rust native libraries for four release targets.", + list(GO_REPORTS.values()), + ) + result[output_dir / "NOTICE"] = binary_notice( + root, [metadata[report] for report in reports] + ) + return result + + +def generated_files(root: Path) -> dict[Path, str]: + verify_python_source_legal_files(root) + reports = report_specs() + generated, metadata = generate_reports(root, reports) + result = {root / report.output: generated[report] for report in reports} + + apache_license = (root / "LICENSE").read_text(encoding="utf-8") + python_reports = [report for report in reports if report.component == "python"] + for report in python_reports: + license_dir = root / "bindings/python/licenses" / report.target + files = python_binary_files( + root, report.target, generated[report], metadata[report] + ) + for name in ("LICENSE", "NOTICE"): + result[license_dir / name] = files[name] + + go_reports = list(GO_REPORTS.values()) + result[root / "bindings/go/LICENSE"] = binary_license( + apache_license, + "This Go module bundles Rust native libraries for four release targets.", + go_reports, + ) + result[root / "bindings/go/NOTICE"] = binary_notice( + root, + [ + metadata[report] + for report in reports + if report.component == "go" + ], + ) + return result + + +def check_generated_files(files: dict[Path, str], root: Path) -> None: + relative_paths = [str(path.relative_to(root)) for path in files] + tracked = subprocess.check_output( + ["git", "ls-files", "--", *relative_paths], cwd=root, text=True + ).splitlines() + if tracked: + raise RuntimeError( + "generated binary legal files must not be tracked: " + + ", ".join(tracked) + ) + + python_paths = [ + relative + for relative in relative_paths + if relative.startswith("bindings/python/licenses/") + ] + for relative in python_paths: + attribute = subprocess.check_output( + ["git", "check-attr", "export-ignore", "--", relative], + cwd=root, + text=True, + ).strip() + if not attribute.endswith(": set"): + raise RuntimeError(f"generated binary legal file is not export-ignored: {relative}") + + print(f"validated {len(files)} generated binary legal files") + + +def write_files(files: dict[Path, str], root: Path) -> None: + for path, content in files.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content.encode("utf-8")) + display_path = path.relative_to(root) if path.is_relative_to(root) else path + print(f"generated {display_path}") + + +def verify_existing_go_reports(files: dict[Path, str]) -> None: + report_names = set(GO_REPORTS.values()) + for path, expected in files.items(): + if path.name not in report_names: + continue + if not path.is_file(): + raise RuntimeError(f"missing generated Go report: {path}") + if path.read_bytes() != expected.encode("utf-8"): + raise RuntimeError(f"generated Go report differs from release input: {path}") + + +def stage_python(root: Path, target: str) -> None: + write_files(python_target_files(root, target, root / "bindings/python"), root) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--check", + action="store_true", + help="validate reproducible binary legal output without writing it", + ) + group.add_argument( + "--stage-python", + metavar="TARGET", + help="stage one target's legal files for a maturin wheel build", + ) + group.add_argument( + "--generate-python-reports", + metavar="DIRECTORY", + help="generate all target legal files for final wheel verification", + ) + group.add_argument( + "--generate-go-report", + nargs=2, + metavar=("TARGET", "DIRECTORY"), + help="generate one target report for a Go build job", + ) + group.add_argument( + "--generate-go-release", + metavar="DIRECTORY", + help="verify Go target reports and generate aggregate legal files", + ) + args = parser.parse_args() + root = repository_root() + + try: + if args.stage_python: + stage_python(root, args.stage_python) + return 0 + if args.generate_python_reports: + files = python_verification_files( + root, Path(args.generate_python_reports).resolve() + ) + write_files(files, root) + return 0 + if args.generate_go_report: + target, directory = args.generate_go_report + files = go_target_files(root, target, Path(directory).resolve()) + write_files(files, root) + return 0 + if args.generate_go_release: + files = go_release_files(root, Path(args.generate_go_release).resolve()) + verify_existing_go_reports(files) + write_files( + { + path: content + for path, content in files.items() + if path.name in {"LICENSE", "NOTICE"} + }, + root, + ) + return 0 + files = generated_files(root) + except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as error: + print(f"release license generation failed: {error}", file=sys.stderr) + return 1 + + if args.check: + try: + check_generated_files(files, root) + except (OSError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"release license generation failed: {error}", file=sys.stderr) + return 1 + return 0 + write_files(files, root) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_release_licenses.py b/scripts/tests/test_release_licenses.py new file mode 100644 index 00000000..1742030e --- /dev/null +++ b/scripts/tests/test_release_licenses.py @@ -0,0 +1,72 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import tempfile +import unittest +from pathlib import Path + +from scripts.release_licenses import binary_notice, resolved_packages + + +class ResolvedPackagesTest(unittest.TestCase): + def test_uses_normal_cargo_tree_packages(self) -> None: + metadata = { + "normal_packages": {("root", "1.0.0"), ("required", "2.0.0")}, + "packages": [ + {"name": "root", "version": "1.0.0"}, + {"name": "required", "version": "2.0.0"}, + {"name": "optional", "version": "3.0.0"}, + ], + } + self.assertEqual( + [package["name"] for package in resolved_packages(metadata)], + ["root", "required"], + ) + + def test_notice_uses_only_normal_dependencies(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / "NOTICE").write_text("Root notice\n", encoding="utf-8") + packages = [] + for name, notice in ( + ("required", "Required dependency notice\n"), + ("optional", "Optional dependency notice\n"), + ): + package_dir = root / name + package_dir.mkdir() + manifest = package_dir / "Cargo.toml" + manifest.write_text("[package]\n", encoding="utf-8") + (package_dir / "NOTICE").write_text(notice, encoding="utf-8") + packages.append( + { + "name": name, + "version": "1.0.0", + "manifest_path": str(manifest), + } + ) + + metadata = { + "normal_packages": {("required", "1.0.0")}, + "packages": packages, + } + notice = binary_notice(root, [metadata]) + self.assertIn("Required dependency notice", notice) + self.assertNotIn("Optional dependency notice", notice) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_verify_python_wheels.py b/scripts/tests/test_verify_python_wheels.py new file mode 100644 index 00000000..3b47cbe7 --- /dev/null +++ b/scripts/tests/test_verify_python_wheels.py @@ -0,0 +1,62 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import tarfile +import unittest + +from scripts.verify_python_wheels import ( + VerificationError, + index_tar_members, + normalized_relative_path, +) + + +class ArchivePathTest(unittest.TestCase): + def test_rejects_non_canonical_paths(self) -> None: + for value in ("root/./LICENSE", "root//LICENSE", "C:/LICENSE"): + with self.subTest(value=value): + with self.assertRaises(VerificationError): + normalized_relative_path(value, "archive member") + + def test_rejects_tar_links(self) -> None: + member = tarfile.TarInfo("root/LICENSE") + member.type = tarfile.SYMTYPE + member.linkname = "NOTICE" + with self.assertRaises(VerificationError): + index_tar_members([member], "sdist") + + def test_rejects_duplicate_tar_members(self) -> None: + first = tarfile.TarInfo("root/LICENSE") + second = tarfile.TarInfo("root/LICENSE") + with self.assertRaises(VerificationError): + index_tar_members([first, second], "sdist") + + def test_rejects_portable_tar_collisions(self) -> None: + upper = tarfile.TarInfo("root/LICENSE") + lower = tarfile.TarInfo("root/license") + with self.assertRaises(VerificationError): + index_tar_members([upper, lower], "sdist") + + def test_indexes_regular_tar_members(self) -> None: + license_member = tarfile.TarInfo("root/LICENSE") + notice_member = tarfile.TarInfo("root/NOTICE") + indexed = index_tar_members([license_member, notice_member], "sdist") + self.assertEqual(set(indexed), {"root/LICENSE", "root/NOTICE"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_go_release_artifacts.py b/scripts/verify_go_release_artifacts.py new file mode 100644 index 00000000..fc8c8e13 --- /dev/null +++ b/scripts/verify_go_release_artifacts.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Verify Go native release artifacts and their target-specific licenses.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Artifact: + library: str + report: str + format_markers: tuple[str, ...] + architecture_markers: tuple[str, ...] + + +ARTIFACTS = { + "x86_64-unknown-linux-gnu": Artifact( + library="libpaimon_c.linux.amd64.so.zst", + report="THIRD-PARTY-LICENSES.linux.amd64.html", + format_markers=("elf 64-bit", "shared object"), + architecture_markers=("x86-64", "x86_64"), + ), + "aarch64-unknown-linux-gnu": Artifact( + library="libpaimon_c.linux.arm64.so.zst", + report="THIRD-PARTY-LICENSES.linux.arm64.html", + format_markers=("elf 64-bit", "shared object"), + architecture_markers=("aarch64", "arm64"), + ), + "x86_64-apple-darwin": Artifact( + library="libpaimon_c.darwin.amd64.dylib.zst", + report="THIRD-PARTY-LICENSES.darwin.amd64.html", + format_markers=("mach-o 64-bit", "dynamically linked shared library"), + architecture_markers=("x86_64", "x86-64"), + ), + "aarch64-apple-darwin": Artifact( + library="libpaimon_c.darwin.arm64.dylib.zst", + report="THIRD-PARTY-LICENSES.darwin.arm64.html", + format_markers=("mach-o 64-bit", "dynamically linked shared library"), + architecture_markers=("arm64", "aarch64"), + ), +} + +LICENSE_PLACEHOLDERS = ( + "<year>", + "<owner>", + "<copyright holders>", + "", + "", + "", +) +AVRO_NOTICE_MARKERS = ( + "Apache Avro", + "Flavien Raynaud", + "donated to the Apache Avro project in 2020.", +) + + +def repository_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def require_file(path: Path) -> None: + if not path.is_file() or path.stat().st_size == 0: + raise ValueError(f"missing or empty release file: {path}") + + +def verify_avro_notice(content: str, description: str) -> None: + for marker in AVRO_NOTICE_MARKERS: + if marker not in content: + raise ValueError(f"{description} is missing {marker!r}") + + +def verify_report(report: Path, target: str) -> None: + require_file(report) + report_text = report.read_text(encoding="utf-8") + required_markers = ( + "Artifact: go", + f"Rust target: {target}", + "Root manifest: bindings/c/Cargo.toml", + ) + for marker in required_markers: + if marker not in report_text: + raise ValueError(f"{report} is missing {marker!r}") + for placeholder in LICENSE_PLACEHOLDERS: + if placeholder in report_text: + raise ValueError(f"{report} contains placeholder {placeholder!r}") + + +def verify_library(library: Path, artifact: Artifact) -> None: + require_file(library) + with tempfile.TemporaryDirectory(prefix="paimon-go-release-") as temp_dir: + unpacked = Path(temp_dir) / library.name.removesuffix(".zst") + with unpacked.open("wb") as destination: + result = subprocess.run( + ["zstd", "-d", "-q", "-c", str(library)], + stdout=destination, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if result.returncode != 0: + raise ValueError(f"cannot decompress {library}: {result.stderr.strip()}") + require_file(unpacked) + description = subprocess.check_output( + ["file", "-b", str(unpacked)], text=True + ).strip() + + normalized = description.lower() + if any(marker not in normalized for marker in artifact.format_markers): + raise ValueError(f"unexpected binary format for {library}: {description}") + if not any(marker in normalized for marker in artifact.architecture_markers): + raise ValueError(f"unexpected binary architecture for {library}: {description}") + + +def verify_target(root: Path, artifacts_dir: Path, target: str) -> None: + artifact = ARTIFACTS[target] + library = artifacts_dir / artifact.library + report = artifacts_dir / artifact.report + verify_library(library, artifact) + verify_report(report, target) + print(f"verified {artifact.library}: {target}") + + +def verify_release_legal_files(root: Path, artifacts_dir: Path) -> None: + license_path = artifacts_dir / "LICENSE" + require_file(license_path) + license_content = license_path.read_text(encoding="utf-8") + apache_license = (root / "LICENSE").read_text(encoding="utf-8").rstrip() + if not license_content.startswith(apache_license + "\n"): + raise ValueError("binary LICENSE does not begin with the Apache License") + for artifact in ARTIFACTS.values(): + if artifact.report not in license_content: + raise ValueError(f"binary LICENSE is missing {artifact.report}") + + notice_path = artifacts_dir / "NOTICE" + require_file(notice_path) + notice = notice_path.read_text(encoding="utf-8") + verify_avro_notice(notice, "NOTICE") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--artifacts-dir", + required=True, + type=Path, + help="directory containing downloaded or locally built release assets", + ) + parser.add_argument( + "--target", + choices=tuple(ARTIFACTS), + help="verify one matrix target; omit to verify the complete release set", + ) + args = parser.parse_args() + + root = repository_root() + artifacts_dir = args.artifacts_dir.resolve() + try: + if args.target: + verify_target(root, artifacts_dir, args.target) + else: + verify_release_legal_files(root, artifacts_dir) + for target in ARTIFACTS: + verify_target(root, artifacts_dir, target) + except (OSError, UnicodeError, ValueError, subprocess.SubprocessError) as error: + print(f"Go release artifact verification failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify_python_wheels.py b/scripts/verify_python_wheels.py new file mode 100644 index 00000000..a01242f3 --- /dev/null +++ b/scripts/verify_python_wheels.py @@ -0,0 +1,654 @@ +#!/usr/bin/env python3 + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Verify Python release archives and their legal metadata.""" + +from __future__ import annotations + +import argparse +import base64 +import csv +import hashlib +import io +import re +import stat +import sys +import tarfile +import tomllib +import unicodedata +import zipfile +from email import policy +from email.message import Message +from email.parser import BytesParser +from pathlib import Path, PurePosixPath + + +ROOT = Path(__file__).resolve().parents[1] +PYTHON_DIR = ROOT / "bindings/python" +LEGAL_FILES = ("LICENSE", "NOTICE", "THIRD-PARTY-LICENSES.html") +TARGETS = { + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", +} +PLACEHOLDERS = ( + b"<year>", + b"<owner>", + b"<copyright holders>", + b"", + b"", + b"", +) +AVRO_NOTICE_MARKERS = ( + b"Apache Avro", + b"Flavien Raynaud", + b"donated to the Apache Avro project in 2020.", +) +XZ_LICENSE_ANCHOR = b'id="bundled-xz-utils-0bsd"' + + +class VerificationError(RuntimeError): + """A release archive failed verification.""" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise VerificationError(message) + + +def normalized_relative_path( + value: str, description: str, allow_trailing_slash: bool = False +) -> PurePosixPath: + require(bool(value), f"empty {description}") + require("\\" not in value, f"invalid {description}: {value!r}") + require( + re.match(r"^[A-Za-z]:", value) is None, + f"Windows drive {description}: {value!r}", + ) + candidate = value[:-1] if allow_trailing_slash and value.endswith("/") else value + path = PurePosixPath(candidate) + require(not path.is_absolute(), f"absolute {description}: {value!r}") + require(".." not in path.parts, f"unsafe {description}: {value!r}") + canonical = path.as_posix() + require( + value == canonical + or (allow_trailing_slash and value == f"{canonical}/"), + f"non-canonical {description}: {value!r}", + ) + return path + + +def portable_path_key(path: PurePosixPath) -> str: + return unicodedata.normalize("NFC", path.as_posix()).casefold() + + +def index_tar_members( + members: list[tarfile.TarInfo], description: str +) -> dict[str, tarfile.TarInfo]: + result = {} + portable_names = set() + for member in members: + require( + member.isfile() or member.isdir(), + f"{description} has a link or special member: {member.name}", + ) + normalized = normalized_relative_path( + member.name, + f"{description} member", + allow_trailing_slash=member.isdir(), + ) + name = normalized.as_posix() + require( + name not in result, + f"{description} has duplicate normalized member: {name}", + ) + portable_name = portable_path_key(normalized) + require( + portable_name not in portable_names, + f"{description} has a portable path collision: {name}", + ) + result[name] = member + portable_names.add(portable_name) + return result + + +def verify_record( + archive: zipfile.ZipFile, + names: list[str], + record_member: str, + description: str, +) -> None: + try: + content = archive.read(record_member).decode("utf-8") + rows = list(csv.reader(io.StringIO(content, newline=""), strict=True)) + except (UnicodeDecodeError, csv.Error) as error: + raise VerificationError(f"invalid RECORD in {description}: {error}") from error + + entries = {} + for row in rows: + require(len(row) == 3, f"invalid RECORD row in {description}: {row}") + name, digest, size = row + normalized_relative_path(name, "RECORD path") + require(name not in entries, f"duplicate RECORD path in {description}: {name}") + entries[name] = (digest, size) + + require( + set(entries) == set(names), + f"RECORD members differ in {description}", + ) + record_dir = PurePosixPath(record_member).parent + unsigned = { + record_member, + str(record_dir / "RECORD.jws"), + str(record_dir / "RECORD.p7s"), + } + for name in names: + digest, size = entries[name] + if name in unsigned: + require(not digest and not size, f"signed RECORD row in {description}: {name}") + continue + data = archive.read(name) + encoded = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=") + require( + digest == f"sha256={encoded.decode()}", + f"RECORD hash differs in {description}: {name}", + ) + require( + size.isdecimal() and int(size) == len(data), + f"RECORD size differs in {description}: {name}", + ) + + +def verify_legal_bytes(content: bytes, description: str) -> None: + require(b"\r" not in content, f"{description} contains CRLF line endings") + lowered = content.lower() + for placeholder in PLACEHOLDERS: + require( + placeholder not in lowered, + f"{description} contains license placeholder {placeholder!r}", + ) + + +def verify_avro_notice(content: bytes, description: str) -> None: + for marker in AVRO_NOTICE_MARKERS: + require(marker in content, f"{description} is missing {marker!r}") + + +def verify_license_report(content: bytes, target: str, description: str) -> None: + markers = ( + b"Artifact: python", + f"Rust target: {target}".encode(), + b"Root manifest: bindings/python/Cargo.toml", + XZ_LICENSE_ANCHOR, + ) + for marker in markers: + require(marker in content, f"{description} is missing {marker!r}") + + +def parse_metadata(content: bytes, description: str) -> Message: + try: + metadata = BytesParser(policy=policy.default).parsebytes(content) + except Exception as error: + raise VerificationError(f"invalid {description}: {error}") from error + require( + metadata.get("Metadata-Version") == "2.4", + f"{description} must use Metadata-Version 2.4", + ) + require( + metadata.get("Name") == "pypaimon-rust", + f"{description} has an invalid package name", + ) + require(bool(metadata.get("Version")), f"{description} has no version") + require( + metadata.get("License-Expression") == "Apache-2.0", + f"{description} has an invalid License-Expression", + ) + license_files = metadata.get_all("License-File", []) + require( + sorted(license_files) == sorted(LEGAL_FILES), + f"{description} has invalid License-File entries: {license_files}", + ) + return metadata + + +def workspace_version() -> str: + with (ROOT / "Cargo.toml").open("rb") as source: + return str(tomllib.load(source)["workspace"]["package"]["version"]) + + +def release_pyproject(source: str, version: str, base_version: str) -> str: + if version == base_version: + return source + dynamic_version = 'dynamic = ["version"]\n' + fixed_version = f'version = "{version}"\n' + dynamic_count = source.count(dynamic_version) + fixed_count = source.count(fixed_version) + if dynamic_count == 1 and fixed_count == 0: + return source.replace(dynamic_version, fixed_version, 1) + require( + dynamic_count == 0 and fixed_count == 1, + "unexpected Python release version setting", + ) + return source + + +def expected_sdist_pyproject(version: str) -> bytes: + source = (PYTHON_DIR / "pyproject.toml").read_text(encoding="utf-8") + source = release_pyproject(source, version, workspace_version()) + python_source = 'python-source = "python"\n' + dependency_groups = "\n[dependency-groups]\n" + require(source.count(python_source) == 1, "unexpected python-source setting") + require( + source.count(dependency_groups) == 1, + "unexpected dependency-groups section", + ) + source = source.replace(python_source, "", 1) + rewrite = ( + 'manifest-path = "bindings/python/Cargo.toml"\n' + + python_source + + dependency_groups + ) + return source.replace(dependency_groups, rewrite, 1).encode() + + +def target_from_wheel_name(path: Path) -> str: + parts = path.name.removesuffix(".whl").rsplit("-", 3) + require(len(parts) == 4, f"invalid wheel filename: {path.name}") + platform_tags = parts[-1].split(".") + targets = set() + for platform_tag in platform_tags: + if platform_tag == "win_amd64": + targets.add("x86_64-pc-windows-msvc") + elif platform_tag.startswith("macosx_") and platform_tag.endswith("_x86_64"): + targets.add("x86_64-apple-darwin") + elif platform_tag.startswith("macosx_") and platform_tag.endswith("_arm64"): + targets.add("aarch64-apple-darwin") + elif re.fullmatch( + r"(?:manylinux(?:\d{4}|_\d+_\d+)|linux)_x86_64", platform_tag + ): + targets.add("x86_64-unknown-linux-gnu") + elif re.fullmatch( + r"(?:manylinux(?:\d{4}|_\d+_\d+)|linux)_aarch64", platform_tag + ): + targets.add("aarch64-unknown-linux-gnu") + else: + raise VerificationError( + f"unsupported wheel platform tag {platform_tag!r}: {path.name}" + ) + require(len(targets) == 1, f"wheel has mixed platform targets: {path.name}") + return targets.pop() + + +def verify_native_header(content: bytes, target: str, description: str) -> None: + if target.endswith("linux-gnu"): + require(content[:4] == b"\x7fELF", f"{description} is not ELF") + require(len(content) >= 20 and content[4] == 2, f"{description} is not ELF64") + byte_order = "little" if content[5] == 1 else "big" + machine = int.from_bytes(content[18:20], byte_order) + expected = 62 if target.startswith("x86_64") else 183 + require(machine == expected, f"{description} has ELF machine {machine}") + return + + if target.endswith("windows-msvc"): + require(content[:2] == b"MZ" and len(content) >= 64, f"{description} is not PE") + pe_offset = int.from_bytes(content[60:64], "little") + require( + len(content) >= pe_offset + 6, f"{description} has a truncated PE header" + ) + require( + content[pe_offset : pe_offset + 4] == b"PE\0\0", f"{description} is not PE" + ) + machine = int.from_bytes(content[pe_offset + 4 : pe_offset + 6], "little") + require(machine == 0x8664, f"{description} has PE machine {machine:#x}") + return + + magic = content[:4] + if magic == b"\xcf\xfa\xed\xfe": + byte_order = "little" + elif magic == b"\xfe\xed\xfa\xcf": + byte_order = "big" + else: + raise VerificationError(f"{description} is not 64-bit Mach-O") + require(len(content) >= 8, f"{description} has a truncated Mach-O header") + cpu_type = int.from_bytes(content[4:8], byte_order) + expected = 0x01000007 if target.startswith("x86_64") else 0x0100000C + require(cpu_type == expected, f"{description} has Mach-O CPU type {cpu_type:#x}") + + +def is_native_library(name: str) -> bool: + filename = PurePosixPath(name).name.lower() + return filename.endswith((".so", ".pyd", ".dylib", ".dll")) or ".so." in filename + + +def verify_wheel(path: Path) -> tuple[str, str]: + target = target_from_wheel_name(path) + expected_dir = PYTHON_DIR / "licenses" / target + if not expected_dir.is_dir(): + expected_dir = PYTHON_DIR + filename_prefix = "pypaimon_rust-" + filename_head = path.name.removesuffix(".whl").rsplit("-", 3)[0] + require( + filename_head.startswith(filename_prefix), + f"invalid wheel distribution name: {path.name}", + ) + filename_version = filename_head.removeprefix(filename_prefix) + + try: + archive = zipfile.ZipFile(path) + except (OSError, zipfile.BadZipFile) as error: + raise VerificationError(f"cannot open wheel {path}: {error}") from error + + with archive: + infos = [info for info in archive.infolist() if not info.is_dir()] + names = [info.filename for info in infos] + portable_names = set() + for info in infos: + mode = info.external_attr >> 16 + file_type = stat.S_IFMT(mode) + require( + file_type in {0, stat.S_IFREG}, + f"wheel has a link or special member: {info.filename}", + ) + normalized = normalized_relative_path(info.filename, "wheel member") + key = portable_path_key(normalized) + require( + key not in portable_names, + f"wheel has a portable path collision: {path.name}:{info.filename}", + ) + portable_names.add(key) + bad_member = archive.testzip() + require(bad_member is None, f"wheel has a corrupt member: {bad_member}") + + metadata_members = [ + name for name in names if name.endswith(".dist-info/METADATA") + ] + require( + len(metadata_members) == 1, f"wheel must contain one METADATA: {path.name}" + ) + metadata_member = metadata_members[0] + metadata = parse_metadata(archive.read(metadata_member), metadata_member) + version = str(metadata["Version"]) + require( + version == filename_version, + f"wheel filename and metadata versions differ: {path.name}", + ) + dist_info = PurePosixPath(metadata_member).parent + + wheel_members = [name for name in names if name == f"{dist_info}/WHEEL"] + require(len(wheel_members) == 1, f"wheel metadata is incomplete: {path.name}") + wheel_metadata = BytesParser(policy=policy.default).parsebytes( + archive.read(wheel_members[0]) + ) + require( + wheel_metadata.get("Root-Is-Purelib", "").lower() == "false", + f"wheel is incorrectly marked pure: {path.name}", + ) + record_member = f"{dist_info}/RECORD" + require(record_member in names, f"wheel has no RECORD: {path.name}") + verify_record(archive, names, record_member, path.name) + + license_report = None + for license_file in metadata.get_all("License-File", []): + relative = normalized_relative_path(license_file, "License-File entry") + members = ( + str(relative), + str(dist_info / "licenses" / relative), + ) + matches = [ + name + for name in names + if PurePosixPath(name).name == relative.name + and relative.name in LEGAL_FILES + ] + require( + sorted(matches) == sorted(members), + f"wheel has invalid {relative.name} members: {matches}", + ) + expected_path = expected_dir / relative + require( + expected_path.is_file(), + f"missing committed legal file: {expected_path}", + ) + expected = expected_path.read_bytes() + for member in members: + actual = archive.read(member) + require( + actual == expected, + f"wheel {member} differs for {target}", + ) + verify_legal_bytes(actual, f"{path.name}:{member}") + if relative.name == "NOTICE": + verify_avro_notice(actual, f"{path.name}:{member}") + if relative.name == "THIRD-PARTY-LICENSES.html": + license_report = actual + verify_license_report(actual, target, f"{path.name}:{member}") + + native_members = [ + name + for name in names + if len(PurePosixPath(name).parts) == 2 + and PurePosixPath(name).parts[0] == "pypaimon_rust" + and PurePosixPath(name).name.startswith("pypaimon_rust.") + and PurePosixPath(name).suffix in {".so", ".pyd"} + ] + require( + len(native_members) == 1, + f"wheel must contain one native module: {path.name}", + ) + native_member = native_members[0] + expected_suffix = ".pyd" if target.endswith("windows-msvc") else ".so" + require( + PurePosixPath(native_member).suffix == expected_suffix, + f"wheel has an invalid native module suffix: {native_member}", + ) + with archive.open(native_member) as native_file: + verify_native_header( + native_file.read(65536), target, f"{path.name}:{native_member}" + ) + + extra_native_members = [ + name for name in names if name != native_member and is_native_library(name) + ] + if target.endswith("linux-gnu"): + openssl_version = ( + b"OpenSSL 1.1.1k FIPS" + if target.startswith("x86_64") + else b"OpenSSL 1.1.1w" + ) + openssl_libraries = ( + (r"pypaimon_rust\.libs/libcrypto-[^/]+\.so\.1\.1", openssl_version), + (r"pypaimon_rust\.libs/libssl-[^/]+\.so\.1\.1", None), + ) + for pattern, version_marker in openssl_libraries: + matches = [ + name for name in extra_native_members if re.fullmatch(pattern, name) + ] + require( + len(matches) == 1, + f"wheel must contain one {pattern}: {extra_native_members}", + ) + with archive.open(matches[0]) as native_file: + content = native_file.read() + verify_native_header( + content, + target, + f"{path.name}:{matches[0]}", + ) + if version_marker is not None: + require( + version_marker in content, + f"wheel libcrypto is missing {version_marker!r}", + ) + require( + len(extra_native_members) == len(openssl_libraries), + f"wheel has unexpected native libraries: {extra_native_members}", + ) + require( + license_report is not None + and b'id="bundled-openssl-1.1.1"' in license_report, + "Linux wheel license report is missing the OpenSSL anchor", + ) + else: + require( + not extra_native_members, + f"wheel has unexpected native libraries: {extra_native_members}", + ) + + return target, version + + +def verify_sdist(path: Path) -> str: + try: + archive = tarfile.open(path, mode="r:gz") + except (OSError, tarfile.TarError) as error: + raise VerificationError(f"cannot open sdist {path}: {error}") from error + + with archive: + members_by_name = index_tar_members( + archive.getmembers(), f"sdist {path.name}" + ) + names = list(members_by_name) + roots = {PurePosixPath(name).parts[0] for name in names} + require(len(roots) == 1, f"sdist must have one root directory: {path.name}") + root = roots.pop() + + def read_member(name: str) -> bytes: + try: + member = members_by_name[name] + except KeyError as error: + raise VerificationError( + f"sdist is missing {name}: {path.name}" + ) from error + require(member.isfile(), f"sdist member is not a file: {name}") + extracted = archive.extractfile(member) + require(extracted is not None, f"cannot read sdist member: {name}") + return extracted.read() + + metadata = parse_metadata(read_member(f"{root}/PKG-INFO"), f"{root}/PKG-INFO") + version = str(metadata["Version"]) + require(root == f"pypaimon_rust-{version}", f"invalid sdist root: {root}") + require( + path.name == f"pypaimon_rust-{version}.tar.gz", + f"invalid sdist filename: {path.name}", + ) + for license_file in metadata.get_all("License-File", []): + relative = normalized_relative_path(license_file, "License-File entry") + member = f"{root}/{relative}" + actual = read_member(member) + expected_path = PYTHON_DIR / relative + require( + expected_path.is_file(), f"missing source legal file: {expected_path}" + ) + require( + actual == expected_path.read_bytes(), + f"sdist {relative} differs from source", + ) + verify_legal_bytes(actual, f"{path.name}:{member}") + + require( + read_member(f"{root}/Cargo.lock") == (ROOT / "Cargo.lock").read_bytes(), + "sdist Cargo.lock differs from source", + ) + require( + read_member(f"{root}/pyproject.toml") == expected_sdist_pyproject(version), + "sdist pyproject.toml has an unexpected rewrite", + ) + target_reports = [ + name for name in names if "/bindings/python/licenses/" in name + ] + require( + not target_reports, + f"sdist contains target-specific license reports: {target_reports}", + ) + native_members = [name for name in names if is_native_library(name)] + require( + not native_members, f"sdist contains native libraries: {native_members}" + ) + return version + + +def collect_artifacts(inputs: list[str]) -> list[Path]: + candidates = [Path(value) for value in inputs] or [PYTHON_DIR / "dist"] + artifacts = [] + for candidate in candidates: + if candidate.is_dir(): + artifacts.extend(sorted(candidate.iterdir())) + else: + artifacts.append(candidate) + artifacts = [ + path + for path in artifacts + if path.name.endswith(".whl") or path.name.endswith(".tar.gz") + ] + unique = list(dict.fromkeys(path.resolve() for path in artifacts)) + require(bool(unique), "no wheel or sdist artifacts found") + for path in unique: + require(path.is_file(), f"artifact is not a file: {path}") + return unique + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("artifacts", nargs="*", help="artifact files or directories") + parser.add_argument( + "--allow-partial", + action="store_true", + help="verify supplied artifacts without requiring the full release set", + ) + args = parser.parse_args() + + try: + artifacts = collect_artifacts(args.artifacts) + targets = {} + sdist_count = 0 + versions = set() + for artifact in artifacts: + if artifact.name.endswith(".whl"): + target, version = verify_wheel(artifact) + require(target not in targets, f"duplicate wheel target: {target}") + targets[target] = artifact + versions.add(version) + print(f"verified {artifact.name} ({target})") + else: + versions.add(verify_sdist(artifact)) + sdist_count += 1 + print(f"verified {artifact.name} (sdist)") + + require(len(versions) == 1, f"artifact versions differ: {sorted(versions)}") + require(sdist_count <= 1, f"expected at most one sdist, found {sdist_count}") + if not args.allow_partial: + missing = sorted(TARGETS - targets.keys()) + require(not missing, f"missing wheel targets: {missing}") + require(sdist_count == 1, f"expected one sdist, found {sdist_count}") + print(f"verified {len(artifacts)} Python release artifacts") + return 0 + except ( + KeyError, + OSError, + VerificationError, + tarfile.TarError, + tomllib.TOMLDecodeError, + zipfile.BadZipFile, + ) as error: + print(f"Python release verification failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/third-party-licenses/README.md b/third-party-licenses/README.md new file mode 100644 index 00000000..642d169a --- /dev/null +++ b/third-party-licenses/README.md @@ -0,0 +1,34 @@ + + +# Bundled third-party licenses + +`openssl-1.1.1.LICENSE` covers OpenSSL 1.1.1k FIPS on x86_64 Linux and +OpenSSL 1.1.1w on aarch64 Linux. Both versions use the same license text. + +The XZ Utils 5.8.3 license is read from the locked `liblzma-sys` crate at +`xz/COPYING.0BSD`. + +Target-specific Python and Go reports are generated in their binary build jobs. +They are intentionally not committed or included in the ASF source archive. + +Sources: + +- +- diff --git a/third-party-licenses/openssl-1.1.1.LICENSE b/third-party-licenses/openssl-1.1.1.LICENSE new file mode 100644 index 00000000..5b5ccdc9 --- /dev/null +++ b/third-party-licenses/openssl-1.1.1.LICENSE @@ -0,0 +1,124 @@ + + LICENSE ISSUES + ============== + + The OpenSSL toolkit stays under a double license, i.e. both the conditions of + the OpenSSL License and the original SSLeay license apply to the toolkit. + See below for the actual license texts. + + OpenSSL License + --------------- + +/* ==================================================================== + * Copyright (c) 1998-2019 The OpenSSL Project. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. All advertising materials mentioning features or use of this + * software must display the following acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit. (http://www.openssl.org/)" + * + * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For written permission, please contact + * openssl-core@openssl.org. + * + * 5. Products derived from this software may not be called "OpenSSL" + * nor may "OpenSSL" appear in their names without prior written + * permission of the OpenSSL Project. + * + * 6. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit (http://www.openssl.org/)" + * + * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY + * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR + * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * ==================================================================== + * + * This product includes cryptographic software written by Eric Young + * (eay@cryptsoft.com). This product includes software written by Tim + * Hudson (tjh@cryptsoft.com). + * + */ + + Original SSLeay License + ----------------------- + +/* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) + * All rights reserved. + * + * This package is an SSL implementation written + * by Eric Young (eay@cryptsoft.com). + * The implementation was written so as to conform with Netscapes SSL. + * + * This library is free for commercial and non-commercial use as long as + * the following conditions are aheared to. The following conditions + * apply to all code found in this distribution, be it the RC4, RSA, + * lhash, DES, etc., code; not just the SSL code. The SSL documentation + * included with this distribution is covered by the same copyright terms + * except that the holder is Tim Hudson (tjh@cryptsoft.com). + * + * Copyright remains Eric Young's, and as such any Copyright notices in + * the code are not to be removed. + * If this package is used in a product, Eric Young should be given attribution + * as the author of the parts of the library used. + * This can be in the form of a textual message at program startup or + * in documentation (online or textual) provided with the package. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. All advertising materials mentioning features or use of this software + * must display the following acknowledgement: + * "This product includes cryptographic software written by + * Eric Young (eay@cryptsoft.com)" + * The word 'cryptographic' can be left out if the rouines from the library + * being used are not cryptographic related :-). + * 4. If you include any Windows specific code (or a derivative thereof) from + * the apps directory (application code) you must include an acknowledgement: + * "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" + * + * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + * + * The licence and distribution terms for any publically available version or + * derivative of this code cannot be changed. i.e. this code cannot simply be + * copied and put under another distribution licence + * [including the GNU Public Licence.] + */