diff --git a/.github/workflows/npm-nested-metadata-validation-quality-ci.yml b/.github/workflows/npm-nested-metadata-validation-quality-ci.yml new file mode 100644 index 000000000..b69b45ab1 --- /dev/null +++ b/.github/workflows/npm-nested-metadata-validation-quality-ci.yml @@ -0,0 +1,108 @@ +name: npm Nested Metadata Validation Quality CI + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/npm-nested-metadata-validation-quality-ci.yml" + - "scripts/ci/materialize_base_javascript_packages.py" + - "tests/test_materialize_base_javascript_packages.py" + - "tests/test_npm_nested_metadata_lock_validation.py" + - "docs/doctoring/npm-nested-metadata-canonical-pins.md" + - "requirements-opencode-review-ci-hashes.txt" + - "CHANGELOG.md" + push: + branches: [main] + paths: + - ".github/workflows/npm-nested-metadata-validation-quality-ci.yml" + - "scripts/ci/materialize_base_javascript_packages.py" + - "tests/test_materialize_base_javascript_packages.py" + - "tests/test_npm_nested_metadata_lock_validation.py" + - "docs/doctoring/npm-nested-metadata-canonical-pins.md" + - "requirements-opencode-review-ci-hashes.txt" + - "CHANGELOG.md" + +concurrency: + group: npm-nested-metadata-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + python-310-compatibility: + name: Python 3.10 compatibility + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Set up Python 3.10 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.10" + - name: Compile implementation and contracts + run: | + python -m compileall -q \ + scripts/ci/materialize_base_javascript_packages.py \ + tests/test_materialize_base_javascript_packages.py \ + tests/test_npm_nested_metadata_lock_validation.py + + python-314-quality: + name: Python 3.14 complete quality + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Set up Python 3.14 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + - name: Install exact hash-locked quality tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + - name: Run focused tests with complete production branch coverage + run: | + python -m coverage erase + python -m coverage run --branch -m pytest -q \ + tests/test_materialize_base_javascript_packages.py \ + tests/test_npm_nested_metadata_lock_validation.py + python -m coverage report \ + --include=scripts/ci/materialize_base_javascript_packages.py \ + --show-missing \ + --fail-under=100 + - name: Enforce complete production docstrings and compilation + run: | + python -m interrogate --fail-under 100 scripts/ci/materialize_base_javascript_packages.py + python -m compileall -q \ + scripts/ci/materialize_base_javascript_packages.py \ + tests/test_materialize_base_javascript_packages.py \ + tests/test_npm_nested_metadata_lock_validation.py + - name: Run complete central regression suite + run: | + python -m coverage erase + python -m coverage run -m pytest tests -q + python -m coverage report --show-missing --fail-under=100 + - name: Verify clean patches + run: git diff --check diff --git a/.github/workflows/pr807-apply-nested-metadata-once.yml b/.github/workflows/pr807-apply-nested-metadata-once.yml new file mode 100644 index 000000000..8ba79814e --- /dev/null +++ b/.github/workflows/pr807-apply-nested-metadata-once.yml @@ -0,0 +1,105 @@ +name: PR 807 Apply Nested npm Metadata Once + +on: + push: + branches: + - fix/npm-nested-metadata-lock-validation + paths: + - .github/workflows/pr807-apply-nested-metadata-once.yml + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + apply: + if: >- + github.repository == 'ContextualWisdomLab/.github' && + github.ref == 'refs/heads/fix/npm-nested-metadata-lock-validation' + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact trigger head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + fetch-depth: 2 + persist-credentials: true + + - name: Refuse concurrent branch movement + env: + EXPECTED_SHA: ${{ github.sha }} + EXPECTED_BRANCH: fix/npm-nested-metadata-lock-validation + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + remote_sha="$(git ls-remote origin "refs/heads/$EXPECTED_BRANCH" | awk '{print $1}')" + test "$remote_sha" = "$EXPECTED_SHA" + + - name: Set up Python 3.14 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install exact hash-locked quality tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Apply reviewed implementation + run: | + python scripts/ci/apply_pr807_nested_metadata.py + rm -- scripts/ci/apply_pr807_nested_metadata.py + git diff --check + + - name: Verify focused and complete quality evidence + run: | + python -m coverage erase + python -m coverage run --branch -m pytest -q \ + tests/test_materialize_base_javascript_packages.py \ + tests/test_npm_nested_metadata_lock_validation.py + python -m coverage report \ + --include=scripts/ci/materialize_base_javascript_packages.py \ + --show-missing \ + --fail-under=100 + python -m interrogate --fail-under 100 scripts/ci/materialize_base_javascript_packages.py + python -m compileall -q \ + scripts/ci/materialize_base_javascript_packages.py \ + tests/test_materialize_base_javascript_packages.py \ + tests/test_npm_nested_metadata_lock_validation.py + python -m coverage erase + python -m coverage run -m pytest tests -q + python -m coverage report --show-missing --fail-under=100 + git diff --check + + - name: Commit permanent non-workflow implementation + env: + EXPECTED_SHA: ${{ github.sha }} + EXPECTED_BRANCH: fix/npm-nested-metadata-lock-validation + run: | + remote_sha="$(git ls-remote origin "refs/heads/$EXPECTED_BRANCH" | awk '{print $1}')" + test "$remote_sha" = "$EXPECTED_SHA" + expected="$(printf '%s\n' \ + 'CHANGELOG.md' \ + 'docs/doctoring/npm-nested-metadata-canonical-pins.md' \ + 'scripts/ci/apply_pr807_nested_metadata.py' \ + 'scripts/ci/materialize_base_javascript_packages.py' | sort)" + actual="$(git diff --name-only | sort)" + test "$actual" = "$expected" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A CHANGELOG.md \ + docs/doctoring/npm-nested-metadata-canonical-pins.md \ + scripts/ci/apply_pr807_nested_metadata.py \ + scripts/ci/materialize_base_javascript_packages.py + git commit -m "fix(coverage): validate nested npm metadata through canonical pins" + test "$(git rev-parse HEAD^)" = "$EXPECTED_SHA" + git push origin "HEAD:refs/heads/$EXPECTED_BRANCH" diff --git a/scripts/ci/apply_pr807_nested_metadata.py b/scripts/ci/apply_pr807_nested_metadata.py new file mode 100644 index 000000000..a169c5c00 --- /dev/null +++ b/scripts/ci/apply_pr807_nested_metadata.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Apply the reviewed PR 807 canonical npm metadata implementation once.""" + +from __future__ import annotations + +from pathlib import Path + + +SOURCE_PATH = Path("scripts/ci/materialize_base_javascript_packages.py") +DOCTORING_PATH = Path("docs/doctoring/npm-nested-metadata-canonical-pins.md") +CHANGELOG_PATH = Path("CHANGELOG.md") + + +HELPERS = r''' + +def _npm_package_identity(candidate: pathlib.PurePosixPath) -> str | None: + """Return the exact package identity after the final node_modules segment.""" + + positions = [ + index for index, segment in enumerate(candidate.parts) if segment == "node_modules" + ] + if not positions: + return None + tail = candidate.parts[positions[-1] + 1 :] + if len(tail) == 1 and not tail[0].startswith("@"): + identity = tail[0] + elif len(tail) == 2 and tail[0].startswith("@"): + identity = f"{tail[0]}/{tail[1]}" + else: + return None + return identity if NPM_PACKAGE_IDENTITY_RE.fullmatch(identity) else None + + +def _validate_npm_registry_pin( + lock_path: str, + package_path: str, + resolved: object, + integrity: object, +) -> None: + """Require one exact public npm tarball and SHA-512 integrity pair.""" + + if not isinstance(resolved, str) or not isinstance(integrity, str): + raise ValueError( + f"current-head npm lock {lock_path} package {package_path} must pin a registry tarball and SHA-512 integrity" + ) + parsed = urllib.parse.urlsplit(resolved) + try: + parsed_port = parsed.port + except ValueError as exc: + raise ValueError( + f"current-head npm lock {lock_path} package {package_path} has an invalid registry URL" + ) from exc + if ( + parsed.scheme != "https" + or parsed.hostname != NPM_REGISTRY_HOST + or parsed.username is not None + or parsed.password is not None + or parsed_port is not None + or parsed.query + or parsed.fragment + or not parsed.path.startswith("/") + or not parsed.path.endswith(".tgz") + ): + raise ValueError( + f"current-head npm lock {lock_path} package {package_path} must resolve from https://{NPM_REGISTRY_HOST}/" + ) + if not SHA512_SRI_RE.fullmatch(integrity): + raise ValueError( + f"current-head npm lock {lock_path} package {package_path} must use one SHA-512 integrity value" + ) + + +def validate_head_npm_lock(lock_path: str, lock_content: bytes) -> None: +''' + + +NEW_LOOP = r''' for package_path, metadata in sorted(packages.items()): + if not isinstance(package_path, str) or not isinstance(metadata, dict): + raise ValueError( + f"current-head npm lock {lock_path} contains malformed package metadata" + ) + if "\\" in package_path: + raise ValueError( + f"current-head npm lock {lock_path} contains unsafe package path {package_path!r}" + ) + candidate = pathlib.PurePosixPath(package_path) + if candidate.is_absolute() or ".." in candidate.parts: + raise ValueError( + f"current-head npm lock {lock_path} contains unsafe package path {package_path!r}" + ) + if not package_path or "node_modules" not in candidate.parts: + continue + + resolved = metadata.get("resolved") + if metadata.get("link") is True: + if not isinstance(resolved, str) or not resolved or "\\" in resolved: + raise ValueError( + f"current-head npm lock {lock_path} contains an unsafe workspace link for {package_path}" + ) + link_target = pathlib.PurePosixPath(resolved) + if ( + link_target.is_absolute() + or ".." in link_target.parts + or "node_modules" in link_target.parts + ): + raise ValueError( + f"current-head npm lock {lock_path} contains an unsafe workspace link for {package_path}" + ) + continue + + identity = _npm_package_identity(candidate) + if identity is None: + raise ValueError( + f"current-head npm lock {lock_path} package {package_path} has a malformed npm package identity" + ) + + has_resolved = "resolved" in metadata + has_integrity = "integrity" in metadata + if has_resolved != has_integrity: + raise ValueError( + f"current-head npm lock {lock_path} package {package_path} must not partially declare resolved or integrity" + ) + if has_resolved: + _validate_npm_registry_pin( + lock_path, + package_path, + metadata.get("resolved"), + metadata.get("integrity"), + ) + continue + + version = metadata.get("version") + if not isinstance(version, str) or not version: + raise ValueError( + f"current-head npm lock {lock_path} package {package_path} must declare a nonempty exact version" + ) + canonical_path = f"node_modules/{identity}" + if package_path == canonical_path: + raise ValueError( + f"current-head npm lock {lock_path} package {package_path} must provide a canonical root pin" + ) + canonical_metadata = packages.get(canonical_path) + if ( + not isinstance(canonical_metadata, dict) + or canonical_metadata.get("link") is True + ): + raise ValueError( + f"current-head npm lock {lock_path} package {package_path} has no canonical root pin at {canonical_path}" + ) + canonical_version = canonical_metadata.get("version") + if not isinstance(canonical_version, str) or not canonical_version: + raise ValueError( + f"current-head npm lock {lock_path} package {package_path} has no canonical root pin at {canonical_path}" + ) + if canonical_version != version: + raise ValueError( + f"current-head npm lock {lock_path} package {package_path} must match the exact canonical version at {canonical_path}" + ) + _validate_npm_registry_pin( + lock_path, + canonical_path, + canonical_metadata.get("resolved"), + canonical_metadata.get("integrity"), + ) +''' + + +DOCTORING = """# Canonical pins for metadata-only nested npm locations + +## Decision + +Changed-head npm lock validation continues to accept only lockfile versions 2 and +3, safe repository-relative package locations, safe workspace links, and exact +public-registry SHA-512 artifact pins. One narrowly defined npm serialization is +also accepted: a non-link nested `node_modules` location may omit `resolved` and +`integrity` only when it declares a nonempty exact `version` and the canonical +root location for the same normalized package identity supplies the same version, +one HTTPS `registry.npmjs.org` tarball, and one valid SHA-512 SRI value. + +For `apps/desktop/node_modules/@types/react-dom`, the only eligible canonical +location is `node_modules/@types/react-dom`. Scoped identity is derived from the +two segments after the final `node_modules`; an unscoped identity uses exactly +one segment. Missing, malformed, linked, version-mismatched, partially pinned, +non-registry, or invalid-integrity canonical evidence fails closed. Complete +nested pins remain independently valid and are not rebound to another version. + +## Trust and interpretation boundary + +The validator consumes the original lock bytes unchanged. It does not repair, +resolve, install, fetch, infer a version range, or synthesize artifact metadata. +The canonical lookup is a structural provenance check for one lock document, not +a claim that arbitrary duplicated locations are interchangeable. Pull-request +code and lifecycle hooks remain outside the trusted materializer. + +npm documents `packages` as a location-keyed map and notes that descriptors may +contain version and classification metadata while artifact fields depend on the +resolved dependency form. npm workspaces are managed from one top-level package +and lock while nested packages are linked into the root installation. This +central policy is intentionally stricter: a metadata-only installed location is +accepted only through one exact root package identity, version, registry origin, +and SHA-512 integrity closure. + +## Verification + +Permanent tests include the BandScope scoped peer shape, an unscoped equivalent, +an independently pinned nested version, missing canonical metadata, version +mismatch, partial pins, hostile registry URLs, invalid SRI, malformed scoped and +unscoped identities, empty versions, and metadata-only root entries. Python 3.10 +compilation and Python 3.14 focused/full tests enforce complete production +statement, branch, and public-docstring coverage. + +## Rollback + +Rollback removes the canonical metadata-only branch and returns to rejecting all +non-link installed locations without local artifact fields. It must not weaken +URL, path, link, lock-version, SHA-512, immutable-source, or offline-execution +controls. + +## References + +npm, Inc. (2026). *package-lock.json*. npm Docs. +https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json/ + +npm, Inc. (2026). *Workspaces*. npm Docs. +https://docs.npmjs.com/cli/v11/using-npm/workspaces/ +""" + + +def main() -> None: + """Apply the bounded implementation, doctoring, and changelog edits.""" + + source = SOURCE_PATH.read_text(encoding="utf-8") + constant_anchor = 'SHA512_SRI_RE = re.compile(r"^sha512-[A-Za-z0-9+/]{86}==$")\n' + constant_replacement = constant_anchor + ( + "NPM_PACKAGE_IDENTITY_RE = re.compile(\n" + ' r"^(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*$"\n' + ")\n" + ) + if source.count(constant_anchor) != 1: + raise SystemExit("npm identity constant anchor changed") + source = source.replace(constant_anchor, constant_replacement, 1) + + function_anchor = ( + "\ndef validate_head_npm_lock(lock_path: str, lock_content: bytes) -> None:\n" + ) + if source.count(function_anchor) != 1: + raise SystemExit("validator function anchor changed") + source = source.replace(function_anchor, HELPERS, 1) + + loop_start = source.index( + " for package_path, metadata in sorted(packages.items()):\n" + ) + loop_end = source.index("\n\ndef materialize(\n", loop_start) + SOURCE_PATH.write_text( + source[:loop_start] + NEW_LOOP + source[loop_end:], + encoding="utf-8", + ) + + DOCTORING_PATH.parent.mkdir(parents=True, exist_ok=True) + DOCTORING_PATH.write_text(DOCTORING, encoding="utf-8") + + changelog = CHANGELOG_PATH.read_text(encoding="utf-8") + fixed_anchor = "### Fixed\n\n" + entry = ( + "- Accepted metadata-only nested npm v2/v3 package locations only when one " + "canonical root package has the same normalized identity and exact version " + "plus a validated public-registry tarball and SHA-512 integrity, while " + "retaining fail-closed path, link, partial-pin, origin, and SRI controls.\n" + ) + if changelog.count(fixed_anchor) != 1: + raise SystemExit("CHANGELOG Fixed anchor changed") + if entry not in changelog: + changelog = changelog.replace(fixed_anchor, fixed_anchor + entry, 1) + CHANGELOG_PATH.write_text(changelog, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tests/test_npm_nested_metadata_lock_validation.py b/tests/test_npm_nested_metadata_lock_validation.py new file mode 100644 index 000000000..a2346606f --- /dev/null +++ b/tests/test_npm_nested_metadata_lock_validation.py @@ -0,0 +1,163 @@ +"""Contracts for npm v2/v3 metadata-only nested package locations.""" + +from __future__ import annotations + +import json + +import pytest + +from scripts.ci import materialize_base_javascript_packages as materializer + + +_VALID_INTEGRITY = "sha512-" + ("A" * 86) + "==" + + +def _pinned(version: str, package_name: str) -> dict[str, str]: + """Return one exact public-registry package pin.""" + + archive_name = package_name.rsplit("/", 1)[-1] + return { + "version": version, + "resolved": ( + f"https://registry.npmjs.org/{package_name}/-/" + f"{archive_name}-{version}.tgz" + ), + "integrity": _VALID_INTEGRITY, + } + + +def _lock(packages: dict[str, object]) -> bytes: + """Serialize one npm lock fixture as UTF-8 JSON bytes.""" + + return json.dumps( + {"lockfileVersion": 3, "packages": packages}, + sort_keys=True, + ).encode("utf-8") + + +def test_accepts_bandscope_scoped_metadata_through_exact_root_pin() -> None: + """A BandScope-shaped peer location may reuse one exact canonical pin.""" + + packages = { + "": {"name": "bandscope"}, + "node_modules/@types/react-dom": _pinned("19.1.7", "@types/react-dom"), + "apps/desktop/node_modules/@types/react-dom": { + "version": "19.1.7", + "dev": True, + "peer": True, + }, + } + + materializer.validate_head_npm_lock("package-lock.json", _lock(packages)) + + +def test_accepts_unscoped_metadata_and_independently_pinned_nested_version() -> None: + """Metadata reuse and an independently complete nested pin can coexist.""" + + packages = { + "node_modules/react": _pinned("19.1.1", "react"), + "apps/web/node_modules/react": {"version": "19.1.1", "peer": True}, + "node_modules/legacy/node_modules/react": _pinned("18.3.1", "react"), + } + + materializer.validate_head_npm_lock("package-lock.json", _lock(packages)) + + +@pytest.mark.parametrize( + ("packages", "message"), + [ + ( + {"apps/web/node_modules/react": {"version": "19.1.1"}}, + "canonical root pin", + ), + ( + { + "node_modules/react": _pinned("19.1.1", "react"), + "apps/web/node_modules/react": {"version": "19.1.0"}, + }, + "exact canonical version", + ), + ( + { + "node_modules/react": { + "version": "19.1.1", + "resolved": _pinned("19.1.1", "react")["resolved"], + }, + "apps/web/node_modules/react": {"version": "19.1.1"}, + }, + "registry tarball and SHA-512 integrity", + ), + ( + { + "node_modules/react": _pinned("19.1.1", "react"), + "apps/web/node_modules/react": { + "version": "19.1.1", + "resolved": _pinned("19.1.1", "react")["resolved"], + }, + }, + "must not partially declare", + ), + ( + { + "node_modules/react": _pinned("19.1.1", "react"), + "apps/web/node_modules/react": { + "version": "19.1.1", + "integrity": _VALID_INTEGRITY, + }, + }, + "must not partially declare", + ), + ( + { + "node_modules/react": { + **_pinned("19.1.1", "react"), + "resolved": "https://example.invalid/react-19.1.1.tgz", + }, + "apps/web/node_modules/react": {"version": "19.1.1"}, + }, + "must resolve from https://registry.npmjs.org/", + ), + ( + { + "node_modules/react": { + **_pinned("19.1.1", "react"), + "integrity": "sha512-invalid", + }, + "apps/web/node_modules/react": {"version": "19.1.1"}, + }, + "must use one SHA-512 integrity value", + ), + ( + {"apps/web/node_modules/@types": {"version": "1.0.0"}}, + "malformed npm package identity", + ), + ( + {"apps/web/node_modules/@types/react/extra": {"version": "1.0.0"}}, + "malformed npm package identity", + ), + ( + { + "node_modules/react": { + "version": "19.1.1", + "dev": True, + } + }, + "canonical root pin", + ), + ( + { + "node_modules/react": _pinned("19.1.1", "react"), + "apps/web/node_modules/react": {"version": ""}, + }, + "nonempty exact version", + ), + ], +) +def test_rejects_untrusted_metadata_only_nested_locations( + packages: dict[str, object], + message: str, +) -> None: + """Every metadata-only location must close through one exact safe root pin.""" + + with pytest.raises(ValueError, match=message): + materializer.validate_head_npm_lock("package-lock.json", _lock(packages))