Skip to content

ci: report-only corresponding-source check for copyleft packages (inbox#283)#415

Open
bryan-minimal wants to merge 2 commits into
mainfrom
ci/corresponding-source-check
Open

ci: report-only corresponding-source check for copyleft packages (inbox#283)#415
bryan-minimal wants to merge 2 commits into
mainfrom
ci/corresponding-source-check

Conversation

@bryan-minimal

@bryan-minimal bryan-minimal commented Jul 14, 2026

Copy link
Copy Markdown
Member

For every copyleft package (classified from license_spdx via prefix match on
expression tokens), verify its exact source archive is durably present on the
public mirror - the corresponding-source offer that distributing copyleft
binaries from the cache obligates. Presence is probed with unauthenticated
HTTPS HEADs against the public bucket (fixed host, charset-validated paths,
no secrets); exactness rides on the build's own sha256 pin. Doubled slashes
are normalized to match minimal's gs:// fetcher (graphviz's build.ncl has a
literal // that builds fine).

First full-catalog run: 127 copyleft packages -> 89 mirrored / 0
mirror-missing / 38 unmirrored (ffmpeg, ghostscript+grafana both AGPL, jdk,
gcc-arm-none-eabi, libvips, x264/x265, ...) - the concrete #283 work list.
Runs on PRs (changed packages), dispatch, and a weekly schedule: mirror
objects can vanish independently of any PR, and that silent drift is the
main thing to catch.

Seventh sibling of the report-only check family; MUST NOT be required.

Co-Authored-By: Claude Fable 5 [email protected]

Summary by CodeRabbit

  • New Features

    • Added automated reporting for copyleft packages to check corresponding source archives on the public mirror.
    • Reports package verification results in a readable GitHub Actions summary, including flagged cases.
    • Runs automatically for relevant pull requests, weekly, or on demand.
  • Chores

    • Checks are informational and non-blocking, so reporting issues do not prevent changes from proceeding.

For every copyleft package (classified from license_spdx via prefix match on
expression tokens), verify its exact source archive is durably present on the
public mirror - the corresponding-source offer that distributing copyleft
binaries from the cache obligates. Presence is probed with unauthenticated
HTTPS HEADs against the public bucket (fixed host, charset-validated paths,
no secrets); exactness rides on the build's own sha256 pin. Doubled slashes
are normalized to match minimal's gs:// fetcher (graphviz's build.ncl has a
literal // that builds fine).

First full-catalog run: 127 copyleft packages -> 89 mirrored / 0
mirror-missing / 38 unmirrored (ffmpeg, ghostscript+grafana both AGPL, jdk,
gcc-arm-none-eabi, libvips, x264/x265, ...) - the concrete #283 work list.
Runs on PRs (changed packages), dispatch, and a weekly schedule: mirror
objects can vanish independently of any PR, and that silent drift is the
main thing to catch.

Seventh sibling of the report-only check family; MUST NOT be required.

Co-Authored-By: Claude Fable 5 <[email protected]>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 46c8fcf3-de39-409a-9107-cbb31e6de6fc

📥 Commits

Reviewing files that changed from the base of the PR and between a4e9d0a and 8bfb808.

📒 Files selected for processing (1)
  • .github/scripts/corresponding_source_report.py
📝 Walkthrough

Walkthrough

Adds a Python reporter that classifies copyleft package source URLs against a public mirror and publishes markdown summaries. Adds a GitHub Actions workflow for pull requests, scheduled runs, and manual execution with non-blocking failure handling.

Changes

Corresponding-source reporting

Layer / File(s) Summary
Metadata extraction and mirror probing
.github/scripts/corresponding_source_report.py
Parses SPDX licenses, URLs, and interpolations; validates mirror paths; probes mirror objects with HTTPS HEAD requests; and emits markdown output.
Package verdict aggregation
.github/scripts/corresponding_source_report.py
Enumerates packages, classifies URLs, selects per-package worst verdicts, aggregates counts, and generates the report.
Workflow package selection and execution
.github/workflows/corresponding-source.yml
Selects changed or all packages, runs the reporter, records no-change summaries, and converts failures into warnings.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GitHubActions
  participant PackageSet
  participant Reporter
  participant BuildFiles
  participant PublicMirror
  participant StepSummary
  GitHubActions->>PackageSet: determine changed or all packages
  PackageSet->>Reporter: pass package list or --all
  Reporter->>BuildFiles: read license_spdx and source URLs
  Reporter->>PublicMirror: probe mirror object with HTTPS HEAD
  PublicMirror-->>Reporter: return presence status
  Reporter->>StepSummary: write markdown verdict report
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the new report-only CI check for copyleft packages and matches the workflow/script changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/corresponding-source-check

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/scripts/corresponding_source_report.py:
- Around line 194-205: Fix the --all filtering around flagged and shown so
unmirrored rows are retained instead of matching the mirrored suffix check.
Preserve each row’s raw verdict separately from the formatted r[2] cell, and
filter using exact verdict equality for mirrored; keep mirror-miss, unmirrored,
and unresolved/unknown rows visible in full-sweep reports while preserving the
existing default-mode behavior.
- Around line 43-90: Update the path validation in head_mirror_object to reject
dot-segment components "." and ".." before invoking curl, while preserving the
existing _SAFE_PATH character validation and None result for invalid paths.
Ensure paths such as "../other-bucket/obj" cannot reach the mirror HEAD request.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 050120c6-6669-40b5-b0ea-4b84d7a844e3

📥 Commits

Reviewing files that changed from the base of the PR and between 96ac3f1 and a4e9d0a.

📒 Files selected for processing (2)
  • .github/scripts/corresponding_source_report.py
  • .github/workflows/corresponding-source.yml

Comment on lines +43 to +90
_SAFE_PATH = re.compile(r"^[A-Za-z0-9._/+~-]+$")

# SPDX id prefixes that carry source obligations on binary distribution.
# Strong and weak/file-level copyleft both create a corresponding-source duty
# for the covered code, so both are in scope. Prefix-matched against expression
# tokens (GPL-2.0-only, LGPL-2.1-or-later, AGPL-3.0-only, MPL-2.0, ...).
_COPYLEFT_PREFIXES = (
"GPL-", "AGPL-", "LGPL-", "SSPL-", "MPL-", "EPL-", "CDDL-", "EUPL-",
"CeCILL", "OSL-", "MS-RL", "CPL-", "IPL-", "RPL-", "CC-BY-SA-",
)
_TOKEN_SPLIT = re.compile(r"[()\s]+")


def copyleft_tokens(expr: str) -> list[str]:
toks = [t for t in _TOKEN_SPLIT.split(expr) if t and t not in ("AND", "OR", "WITH")]
return [t for t in toks if t.startswith(_COPYLEFT_PREFIXES)]


def resolve_interpolations(url: str, lets: dict[str, str]) -> str:
# let-bindings can reference each other (dl_base uses %{version}); a few
# passes of textual substitution resolves the practical cases.
for _ in range(5):
expanded = _INTERP_RE.sub(lambda m: lets.get(m.group(1), m.group(0)), url)
if expanded == url:
break
url = expanded
return url


def head_mirror_object(path: str) -> bool | None:
"""True/False = object present/absent on the public mirror; None = probe
failed (network/5xx) — reported as unknown rather than missing."""
if not _SAFE_PATH.match(path):
return None
try:
p = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--head", "--proto", "=https", "--max-time", "20",
_MIRROR_HTTP + path],
capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
return None
code = p.stdout.strip()
if code == "200":
return True
if code in ("403", "404"): # public bucket: absent objects read as 403/404
return False
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File outline ==\n'
ast-grep outline .github/scripts/corresponding_source_report.py --view expanded || true

printf '\n== Relevant file excerpt ==\n'
nl -ba .github/scripts/corresponding_source_report.py | sed -n '1,180p'

printf '\n== curl path normalization probe ==\n'
python3 - <<'PY'
import subprocess, json, os, textwrap, shlex, sys

tests = [
    "https://example.com/a/../b",
    "https://example.com/a/./b",
    "https://example.com/../b",
    "https://example.com/b/../../c",
]
for u in tests:
    out = subprocess.run(
        ["curl", "-sS", "-o", "/dev/null", "-w", "%{url_effective}", u],
        capture_output=True, text=True
    )
    print(f"input={u}")
    print(f"rc={out.returncode}")
    print(f"url_effective={out.stdout.strip()}")
    if out.stderr.strip():
        print(f"stderr={out.stderr.strip()}")
    print("-" * 40)

print("\n-- path-as-is comparison --")
for u in tests:
    out = subprocess.run(
        ["curl", "--path-as-is", "-sS", "-o", "/dev/null", "-w", "%{url_effective}", u],
        capture_output=True, text=True
    )
    print(f"input={u}")
    print(f"rc={out.returncode}")
    print(f"url_effective={out.stdout.strip()}")
    if out.stderr.strip():
        print(f"stderr={out.stderr.strip()}")
    print("-" * 40)
PY

Repository: gominimal/pkgs

Length of output: 1113


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Focused search for the surrounding constants and any other URL validation.
rg -n "_SAFE_PATH|_MIRROR_HTTP|head_mirror_object|resolve_interpolations|copyleft_tokens|SSRF|path-as-is" .github/scripts/corresponding_source_report.py

# Show the file slice around the relevant logic.
nl -ba .github/scripts/corresponding_source_report.py | sed -n '1,130p'

Repository: gominimal/pkgs

Length of output: 826


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Relevant file excerpt ==\n'
sed -n '37,85p' .github/scripts/corresponding_source_report.py | cat -n

printf '\n== curl path normalization probe ==\n'
python3 - <<'PY'
import subprocess

tests = [
    "https://example.com/a/../b",
    "https://example.com/a/./b",
    "https://example.com/../b",
    "https://example.com/b/../../c",
]

for extra in ([], ["--path-as-is"]):
    print("ARGS:", " ".join(extra) or "(default)")
    for u in tests:
        p = subprocess.run(
            ["curl", *extra, "-sS", "-o", "/dev/null", "-w", "%{url_effective}", u],
            capture_output=True, text=True
        )
        print(f"input={u}")
        print(f"rc={p.returncode}")
        print(f"url_effective={p.stdout.strip()}")
        if p.stderr.strip():
            print(f"stderr={p.stderr.strip()}")
        print("-" * 30)
PY

Repository: gominimal/pkgs

Length of output: 3624


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect how paths are derived and how the result is emitted.
sed -n '130,180p' .github/scripts/corresponding_source_report.py | cat -n

Repository: gominimal/pkgs

Length of output: 2894


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '130,180p' .github/scripts/corresponding_source_report.py | cat -n

Repository: gominimal/pkgs

Length of output: 2894


Reject dot-segments in mirror paths _SAFE_PATH still allows . and .., and curl normalizes them by default. A path like ../other-bucket/obj can be rewritten to a different bucket before the HEAD request is sent, so reject dot-segments before probing the mirror.

🔒️ Proposed fix
 def head_mirror_object(path: str) -> bool | None:
     """True/False = object present/absent on the public mirror; None = probe
     failed (network/5xx) — reported as unknown rather than missing."""
-    if not _SAFE_PATH.match(path):
+    if not _SAFE_PATH.match(path) or any(seg in ("", ".", "..") for seg in path.split("/")):
         return None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_SAFE_PATH = re.compile(r"^[A-Za-z0-9._/+~-]+$")
# SPDX id prefixes that carry source obligations on binary distribution.
# Strong and weak/file-level copyleft both create a corresponding-source duty
# for the covered code, so both are in scope. Prefix-matched against expression
# tokens (GPL-2.0-only, LGPL-2.1-or-later, AGPL-3.0-only, MPL-2.0, ...).
_COPYLEFT_PREFIXES = (
"GPL-", "AGPL-", "LGPL-", "SSPL-", "MPL-", "EPL-", "CDDL-", "EUPL-",
"CeCILL", "OSL-", "MS-RL", "CPL-", "IPL-", "RPL-", "CC-BY-SA-",
)
_TOKEN_SPLIT = re.compile(r"[()\s]+")
def copyleft_tokens(expr: str) -> list[str]:
toks = [t for t in _TOKEN_SPLIT.split(expr) if t and t not in ("AND", "OR", "WITH")]
return [t for t in toks if t.startswith(_COPYLEFT_PREFIXES)]
def resolve_interpolations(url: str, lets: dict[str, str]) -> str:
# let-bindings can reference each other (dl_base uses %{version}); a few
# passes of textual substitution resolves the practical cases.
for _ in range(5):
expanded = _INTERP_RE.sub(lambda m: lets.get(m.group(1), m.group(0)), url)
if expanded == url:
break
url = expanded
return url
def head_mirror_object(path: str) -> bool | None:
"""True/False = object present/absent on the public mirror; None = probe
failed (network/5xx) — reported as unknown rather than missing."""
if not _SAFE_PATH.match(path):
return None
try:
p = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--head", "--proto", "=https", "--max-time", "20",
_MIRROR_HTTP + path],
capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
return None
code = p.stdout.strip()
if code == "200":
return True
if code in ("403", "404"): # public bucket: absent objects read as 403/404
return False
return None
def head_mirror_object(path: str) -> bool | None:
"""True/False = object present/absent on the public mirror; None = probe
failed (network/5xx) — reported as unknown rather than missing."""
if not _SAFE_PATH.match(path) or any(seg in ("", ".", "..") for seg in path.split("/")):
return None
try:
p = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--head", "--proto", "=https", "--max-time", "20",
_MIRROR_HTTP + path],
capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
return None
code = p.stdout.strip()
if code == "200":
return True
if code in ("403", "404"): # public bucket: absent objects read as 403/404
return False
return None
🧰 Tools
🪛 ast-grep (0.44.1)

[error] 77-81: Command coming from incoming request
Context: subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--head", "--proto", "=https", "--max-time", "20",
_MIRROR_HTTP + path],
capture_output=True, text=True, timeout=30)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)

🪛 Ruff (0.15.21)

[error] 78-78: subprocess call: check for execution of untrusted input

(S603)


[error] 79-81: Starting a process with a partial executable path

(S607)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/corresponding_source_report.py around lines 43 - 90, Update
the path validation in head_mirror_object to reject dot-segment components "."
and ".." before invoking curl, while preserving the existing _SAFE_PATH
character validation and None result for invalid paths. Ensure paths such as
"../other-bucket/obj" cannot reach the mirror HEAD request.

Comment thread .github/scripts/corresponding_source_report.py Outdated
…fixes

The inbox#283 backfill uploads archives to the mirror WITHOUT touching
build.ncl (repointing changes spec_hashes -> rebuild cascades; URLs get
repointed lazily at natural version bumps). Teach the check to probe the
conventional mirror path (github -> owner/repo/basename, else flat basename)
for upstream-URL packages and report 'mirrored (out-of-band)' instead of
falsely flagging them unmirrored.

Probe fixes caught by the backfill itself: underscore was missing from the
path charset (jdk's OpenJDK21U-jdk_x64_... failed validation before probing),
and a failed probe in the out-of-band branch collapsed into 'unmirrored'
instead of 'probe-unknown'.

Full-catalog state after backfill: 127/127 copyleft packages mirrored
(89 by URL, 38 out-of-band), 0 missing, 0 unmirrored.

Co-Authored-By: Claude Fable 5 <[email protected]>
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.

1 participant